mcp-server-kit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ # Secret scan on every push and PR: the public snickery/secret-gate action. A
2
+ # short ref, so it resolves through the forge's DEFAULT_ACTIONS_URL (GitHub)
3
+ # and this public repo's workflow carries no instance hostname.
4
+ name: security-gate
5
+
6
+ on:
7
+ push:
8
+ pull_request:
9
+
10
+ # Never cancel: Forgejo otherwise cancels a branch's queued runs of this
11
+ # workflow when a newer push lands, and each run scans only its own push, so a
12
+ # cancelled run is an unscanned push. (forgejo-ci-watch rescans any that are.)
13
+ concurrency:
14
+ cancel-in-progress: false
15
+
16
+ jobs:
17
+ secret-scan:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - name: Checkout (full history for diff range)
21
+ uses: actions/checkout@v4
22
+ with:
23
+ fetch-depth: 0
24
+
25
+ - name: Secret scan
26
+ uses: snickery/secret-gate@v1
27
+ with:
28
+ base: ${{ github.event.pull_request.base.sha || github.event.before }}
29
+ head: ${{ github.event.pull_request.head.sha || github.sha }}
@@ -0,0 +1,30 @@
1
+ # Unit-test job: runs the pytest suite on every push/PR. Identical across the
2
+ # first-party MCP repos.
3
+ # Credential-free by design: integration/live tests self-skip without their
4
+ # env vars — never add secrets here. Python = .python-version, else 3.13
5
+ # (the Containerfile base). Forgejo ignores .github/workflows while
6
+ # .forgejo/workflows exists, so this is the one that runs on the forge.
7
+ name: tests
8
+
9
+ on:
10
+ push:
11
+ pull_request:
12
+ workflow_dispatch:
13
+
14
+ jobs:
15
+ pytest:
16
+ runs-on: ubuntu-latest
17
+ timeout-minutes: 20
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Install uv + Python
22
+ run: |
23
+ curl -LsSf https://astral.sh/uv/install.sh | sh
24
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
25
+ py="$(cat .python-version 2>/dev/null || echo 3.13)"
26
+ echo "UV_PYTHON=$py" >> "$GITHUB_ENV"
27
+ "$HOME/.local/bin/uv" python install "$py"
28
+
29
+ - name: pytest
30
+ run: uv run --isolated --frozen --extra test python -m pytest -q
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: uv sync --frozen --extra test
20
+ - run: uv run pytest tests/ -q
@@ -0,0 +1,21 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes via PyPI Trusted Publishing (OIDC) — no stored token.
4
+ # Fires on a published GitHub release, or manually via workflow_dispatch.
5
+ on:
6
+ release:
7
+ types: [published]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ permissions:
15
+ id-token: write
16
+ contents: read
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: astral-sh/setup-uv@v5
20
+ - run: uv build
21
+ - run: uv publish
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .secrets.env
6
+ dist/
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nick Engel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-server-kit
3
+ Version: 0.1.0
4
+ Summary: Shared plumbing for FastMCP servers: bearer auth, off-event-loop sync tools with per-call logging, stdio/HTTP serving, and a liveness probe
5
+ Project-URL: Homepage, https://github.com/snickery/mcp-server-kit
6
+ Project-URL: Issues, https://github.com/snickery/mcp-server-kit/issues
7
+ Author-email: Nick Engel <nick@engel.au>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: auth,fastmcp,mcp,model-context-protocol,server
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: anyio>=4.0
18
+ Requires-Dist: mcp[cli]<2,>=1.0.0
19
+ Requires-Dist: uvicorn>=0.32.0
20
+ Provides-Extra: pdf
21
+ Requires-Dist: httpx>=0.27; extra == 'pdf'
22
+ Requires-Dist: pdfplumber>=0.11; extra == 'pdf'
23
+ Requires-Dist: pillow>=10.0; extra == 'pdf'
24
+ Requires-Dist: pypdfium2>=4.30; extra == 'pdf'
25
+ Provides-Extra: test
26
+ Requires-Dist: httpx>=0.27; extra == 'test'
27
+ Requires-Dist: httpx>=0.27.0; extra == 'test'
28
+ Requires-Dist: pdfplumber>=0.11; extra == 'test'
29
+ Requires-Dist: pillow>=10.0; extra == 'test'
30
+ Requires-Dist: pypdfium2>=4.30; extra == 'test'
31
+ Requires-Dist: pytest>=8.0; extra == 'test'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # mcp-server-kit
35
+
36
+ Shared plumbing for Python MCP servers built on the official SDK's FastMCP
37
+ (`mcp.server.fastmcp`). It covers the parts every server ends up copying:
38
+
39
+ | Module | What it does |
40
+ |---|---|
41
+ | `offload_sync_tools` | Registers each **sync** tool as an async wrapper that runs the body in a worker thread, so one blocking upstream call can't freeze every other request. The SDK runs sync handlers inline on the event loop. Signatures, and so input schemas, are unchanged. Every call, sync or async, logs one `tool=… outcome=… duration_ms=… rss_mib=…` line. |
42
+ | `BearerAuthMiddleware` | Pure ASGI bearer-token gate (timing-safe compare, streaming-safe). Only `/.well-known/` discovery paths are exempt, matched by prefix, never by substring. |
43
+ | `serve` | `--stdio` runs stdio. Otherwise it serves Streamable HTTP at `/mcp` behind the bearer gate, and **refuses to start without a token**. |
44
+ | `healthcheck.probe` | A liveness probe for containers: a full HTTP round-trip to `/mcp`, where the 401 counts as alive. Pair it with restart-on-unhealthy so a wedged loop heals itself. |
45
+ | `files` | Collision-safe staging for a directory several servers share: bare names only, never overwrite (`-2`, `-3`, … before the extension, `O_CREAT\|O_EXCL`), `sha256` of every transfer. `atomic_create` writes bytes; `exclusive_open` hands a streaming writer the fd. |
46
+ | `pdf` (extra `[pdf]`) | PDF text extraction (pdfplumber text / layout / tables), page rendering (pypdfium2), and OCR through an [xberg](https://github.com/xberg-io/xberg) server (tesseract, or a vision model for hard pages). Configured by `XBERG_*` env vars; the PDF libraries load lazily. |
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ uv add mcp-server-kit # or: pip install mcp-server-kit
52
+ ```
53
+
54
+ It pins `mcp>=1,<2`, so the SDK range lives in one place for every server built on the kit.
55
+
56
+ ## Use
57
+
58
+ ```python
59
+ import logging
60
+ import os
61
+
62
+ from mcp.server.fastmcp import FastMCP
63
+ from mcp_server_kit import offload_sync_tools, serve
64
+
65
+ mcp = FastMCP("example", instructions="...", stateless_http=True, json_response=True)
66
+ offload_sync_tools(mcp, logger_name="example_mcp.requests") # before the first @mcp.tool()
67
+
68
+
69
+ @mcp.tool()
70
+ def lookup(query: str) -> str:
71
+ """A blocking call is fine here: it runs in a worker thread."""
72
+ ...
73
+
74
+
75
+ def main() -> None:
76
+ logging.basicConfig(level=logging.INFO)
77
+ serve(mcp, port=8000, bearer_token=os.environ.get("MCP_BEARER_TOKEN", ""))
78
+ ```
79
+
80
+ `lookup` stays a plain function, so tests and other tools can call it directly.
81
+
82
+ To give every tool the same annotations unless it passes its own:
83
+ `offload_sync_tools(mcp, logger_name=..., default_annotations=ToolAnnotations(readOnlyHint=True))`.
84
+
85
+ Healthcheck module for a container (`HealthCmd=python -m example_mcp.healthcheck`):
86
+
87
+ ```python
88
+ from mcp_server_kit.healthcheck import probe
89
+
90
+ def main() -> None:
91
+ probe(8000)
92
+
93
+ if __name__ == "__main__":
94
+ main()
95
+ ```
96
+
97
+ Shared non-thread-safe clients (e.g. cached `httplib2` objects) need a lock in the
98
+ server. Thread-safe ones (`httpx.Client`, `requests.Session`) don't.
99
+
100
+ ## Develop
101
+
102
+ ```bash
103
+ uv sync --extra test
104
+ uv run pytest -q
105
+ ```
106
+
107
+ MIT licensed.
@@ -0,0 +1,74 @@
1
+ # mcp-server-kit
2
+
3
+ Shared plumbing for Python MCP servers built on the official SDK's FastMCP
4
+ (`mcp.server.fastmcp`). It covers the parts every server ends up copying:
5
+
6
+ | Module | What it does |
7
+ |---|---|
8
+ | `offload_sync_tools` | Registers each **sync** tool as an async wrapper that runs the body in a worker thread, so one blocking upstream call can't freeze every other request. The SDK runs sync handlers inline on the event loop. Signatures, and so input schemas, are unchanged. Every call, sync or async, logs one `tool=… outcome=… duration_ms=… rss_mib=…` line. |
9
+ | `BearerAuthMiddleware` | Pure ASGI bearer-token gate (timing-safe compare, streaming-safe). Only `/.well-known/` discovery paths are exempt, matched by prefix, never by substring. |
10
+ | `serve` | `--stdio` runs stdio. Otherwise it serves Streamable HTTP at `/mcp` behind the bearer gate, and **refuses to start without a token**. |
11
+ | `healthcheck.probe` | A liveness probe for containers: a full HTTP round-trip to `/mcp`, where the 401 counts as alive. Pair it with restart-on-unhealthy so a wedged loop heals itself. |
12
+ | `files` | Collision-safe staging for a directory several servers share: bare names only, never overwrite (`-2`, `-3`, … before the extension, `O_CREAT\|O_EXCL`), `sha256` of every transfer. `atomic_create` writes bytes; `exclusive_open` hands a streaming writer the fd. |
13
+ | `pdf` (extra `[pdf]`) | PDF text extraction (pdfplumber text / layout / tables), page rendering (pypdfium2), and OCR through an [xberg](https://github.com/xberg-io/xberg) server (tesseract, or a vision model for hard pages). Configured by `XBERG_*` env vars; the PDF libraries load lazily. |
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ uv add mcp-server-kit # or: pip install mcp-server-kit
19
+ ```
20
+
21
+ It pins `mcp>=1,<2`, so the SDK range lives in one place for every server built on the kit.
22
+
23
+ ## Use
24
+
25
+ ```python
26
+ import logging
27
+ import os
28
+
29
+ from mcp.server.fastmcp import FastMCP
30
+ from mcp_server_kit import offload_sync_tools, serve
31
+
32
+ mcp = FastMCP("example", instructions="...", stateless_http=True, json_response=True)
33
+ offload_sync_tools(mcp, logger_name="example_mcp.requests") # before the first @mcp.tool()
34
+
35
+
36
+ @mcp.tool()
37
+ def lookup(query: str) -> str:
38
+ """A blocking call is fine here: it runs in a worker thread."""
39
+ ...
40
+
41
+
42
+ def main() -> None:
43
+ logging.basicConfig(level=logging.INFO)
44
+ serve(mcp, port=8000, bearer_token=os.environ.get("MCP_BEARER_TOKEN", ""))
45
+ ```
46
+
47
+ `lookup` stays a plain function, so tests and other tools can call it directly.
48
+
49
+ To give every tool the same annotations unless it passes its own:
50
+ `offload_sync_tools(mcp, logger_name=..., default_annotations=ToolAnnotations(readOnlyHint=True))`.
51
+
52
+ Healthcheck module for a container (`HealthCmd=python -m example_mcp.healthcheck`):
53
+
54
+ ```python
55
+ from mcp_server_kit.healthcheck import probe
56
+
57
+ def main() -> None:
58
+ probe(8000)
59
+
60
+ if __name__ == "__main__":
61
+ main()
62
+ ```
63
+
64
+ Shared non-thread-safe clients (e.g. cached `httplib2` objects) need a lock in the
65
+ server. Thread-safe ones (`httpx.Client`, `requests.Session`) don't.
66
+
67
+ ## Develop
68
+
69
+ ```bash
70
+ uv sync --extra test
71
+ uv run pytest -q
72
+ ```
73
+
74
+ MIT licensed.
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "mcp-server-kit"
3
+ version = "0.1.0"
4
+ description = "Shared plumbing for FastMCP servers: bearer auth, off-event-loop sync tools with per-call logging, stdio/HTTP serving, and a liveness probe"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Nick Engel", email = "nick@engel.au" }]
9
+ requires-python = ">=3.12"
10
+ keywords = ["mcp", "model-context-protocol", "fastmcp", "server", "auth"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Framework :: AsyncIO",
17
+ ]
18
+ dependencies = [
19
+ # The kit owns the SDK range for every server built on it. <2: mcp 2.0
20
+ # moved FastMCP out of mcp.server.fastmcp; moving to 2.x is a kit release.
21
+ "mcp[cli]>=1.0.0,<2",
22
+ "anyio>=4.0",
23
+ "uvicorn>=0.32.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/snickery/mcp-server-kit"
28
+ Issues = "https://github.com/snickery/mcp-server-kit/issues"
29
+
30
+ [project.optional-dependencies]
31
+ # mcp_server_kit.pdf: native text (pdfplumber), page rasterising (pypdfium2 + pillow),
32
+ # OCR through an xberg server over httpx. Imported lazily, only on a PDF call.
33
+ pdf = ["pdfplumber>=0.11", "pypdfium2>=4.30", "pillow>=10.0", "httpx>=0.27"]
34
+ test = ["pytest>=8.0", "httpx>=0.27.0", "mcp-server-kit[pdf]"]
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/mcp_server_kit"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
@@ -0,0 +1,9 @@
1
+ """Shared plumbing for FastMCP servers: bearer auth, off-loop tools, serving, healthcheck,
2
+ collision-safe file staging (`files`) and PDF reading (`pdf`, extra `[pdf]`)."""
3
+
4
+ from mcp_server_kit.auth import BearerAuthMiddleware
5
+ from mcp_server_kit.serve import serve
6
+ from mcp_server_kit.tools import offload_sync_tools, request_logger, rss_mib
7
+
8
+ __all__ = ["BearerAuthMiddleware", "offload_sync_tools", "request_logger", "rss_mib", "serve"]
9
+ __version__ = "0.1.0"
@@ -0,0 +1,48 @@
1
+ """Bearer-token gate for a Streamable HTTP MCP app."""
2
+
3
+ import hmac
4
+ import json
5
+ import logging
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ _LOOPBACK = ("127.0.0.1", "::1")
10
+
11
+
12
+ class BearerAuthMiddleware:
13
+ """Pure ASGI bearer-token middleware (streaming-safe, timing-safe compare).
14
+
15
+ Every HTTP/websocket request needs `Authorization: Bearer <expected_token>`,
16
+ except `/.well-known/` discovery paths. That exemption is a prefix match on
17
+ purpose: a substring match would become an auth bypass the moment the app
18
+ routes anything under /mcp. Rejections get a JSON 401. A loopback request
19
+ with no credentials (a liveness probe) is logged at debug; anything else is
20
+ a warning. The token itself is never logged.
21
+ """
22
+
23
+ def __init__(self, app, expected_token: str):
24
+ if not expected_token:
25
+ raise ValueError("BearerAuthMiddleware needs a non-empty token")
26
+ self.app = app
27
+ self.expected_token = expected_token
28
+
29
+ async def __call__(self, scope, receive, send):
30
+ if scope["type"] not in ("http", "websocket"):
31
+ return await self.app(scope, receive, send)
32
+ if scope.get("path", "").startswith("/.well-known/"):
33
+ return await self.app(scope, receive, send)
34
+ auth = dict(scope.get("headers", [])).get(b"authorization", b"").decode()
35
+ if auth.startswith("Bearer ") and hmac.compare_digest(auth[7:], self.expected_token):
36
+ return await self.app(scope, receive, send)
37
+
38
+ client_host = (scope.get("client") or ("unknown",))[0]
39
+ if client_host in _LOOPBACK and not auth:
40
+ logger.debug("Health probe 401 (loopback, no credentials, expected)")
41
+ else:
42
+ logger.warning("Rejected request: invalid or missing bearer token from %s", client_host)
43
+ body = json.dumps({"error": "unauthorized"}).encode()
44
+ await send({"type": "http.response.start", "status": 401, "headers": [
45
+ [b"content-type", b"application/json"],
46
+ [b"content-length", str(len(body)).encode()],
47
+ ]})
48
+ await send({"type": "http.response.body", "body": body})
@@ -0,0 +1,62 @@
1
+ """Collision-safe file staging, e.g. a directory shared by several MCP servers.
2
+
3
+ Servers that hand files to each other through a shared directory (one writes
4
+ `shared:<name>`, another reads it) need identical semantics, or a file staged
5
+ by one isn't where the other looks: bare filenames only, never overwrite, and
6
+ on a collision append -2, -3, ... before the extension. Creation uses
7
+ O_CREAT|O_EXCL, so parallel writers in different processes can't clobber each
8
+ other. Every transfer reports the sha256 of the bytes, so the caller can verify
9
+ a handoff in-band.
10
+ """
11
+
12
+ import hashlib
13
+ import os
14
+ from pathlib import Path
15
+
16
+ MAX_SUFFIX = 1000
17
+
18
+
19
+ class NoFreeNameError(RuntimeError):
20
+ """Every candidate name up to -MAX_SUFFIX already exists."""
21
+
22
+
23
+ def sha256(data: bytes) -> str:
24
+ return hashlib.sha256(data).hexdigest()
25
+
26
+
27
+ def require_bare(name: str, what: str = "filename") -> str:
28
+ """`name`, if it is a bare filename (no directory part, not . or ..); else ValueError."""
29
+ if not name or name != os.path.basename(name) or name in (".", ".."):
30
+ raise ValueError(f"Invalid {what} {name!r} — must be a bare filename.")
31
+ return name
32
+
33
+
34
+ def exclusive_open(root: Path, filename: str, mode: int = 0o644) -> tuple[Path, int]:
35
+ """Create a new file under `root` and return (path, fd) open for writing.
36
+
37
+ Tries `filename`, then `<stem>-2<ext>`, `<stem>-3<ext>`, ... `filename` must already
38
+ be bare (see require_bare). For streaming writers; `atomic_create` writes bytes.
39
+ """
40
+ root = Path(root)
41
+ root.mkdir(parents=True, exist_ok=True)
42
+ stem, ext = os.path.splitext(filename)
43
+ for i in range(MAX_SUFFIX + 1):
44
+ candidate = root / (filename if i == 0 else f"{stem}-{i + 1}{ext}")
45
+ try:
46
+ return candidate, os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
47
+ except FileExistsError:
48
+ continue
49
+ raise NoFreeNameError(f"No free filename for {filename!r} in {root} (tried -{MAX_SUFFIX}).")
50
+
51
+
52
+ def atomic_create(root: Path, filename: str, data: bytes) -> Path:
53
+ """Write `data` to a new file under `root` (see exclusive_open) and return its path.
54
+ A failed write removes the partial file."""
55
+ path, fd = exclusive_open(root, filename)
56
+ try:
57
+ with os.fdopen(fd, "wb") as fp:
58
+ fp.write(data)
59
+ except BaseException:
60
+ path.unlink(missing_ok=True)
61
+ raise
62
+ return path
@@ -0,0 +1,29 @@
1
+ """Container liveness probe for a kit-served MCP server.
2
+
3
+ A full HTTP round-trip to /mcp within a short timeout proves the event loop is
4
+ alive. The bearer middleware's 401 to this unauthenticated loopback probe is a
5
+ completed response and counts as healthy. A transport failure or timeout exits
6
+ non-zero, so a restart-on-unhealthy policy (Podman `HealthOnFailure=kill`)
7
+ self-heals a wedged loop. Wire it from the server's own module:
8
+
9
+ # <package>/healthcheck.py
10
+ from mcp_server_kit.healthcheck import probe
11
+ from <package>.config import PORT
12
+
13
+ def main() -> None:
14
+ probe(PORT)
15
+ """
16
+
17
+ import sys
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+
22
+ def probe(port: int, timeout: float = 5) -> None:
23
+ try:
24
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/mcp", timeout=timeout)
25
+ except urllib.error.HTTPError:
26
+ return
27
+ except Exception as exc: # noqa: BLE001 - any transport failure = unhealthy
28
+ print(f"health: unreachable on :{port}: {exc}", file=sys.stderr)
29
+ sys.exit(1)