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.
- mcp_server_kit-0.1.0/.forgejo/workflows/security-gate.yml +29 -0
- mcp_server_kit-0.1.0/.forgejo/workflows/tests.yml +30 -0
- mcp_server_kit-0.1.0/.github/workflows/ci.yml +20 -0
- mcp_server_kit-0.1.0/.github/workflows/publish.yml +21 -0
- mcp_server_kit-0.1.0/.gitignore +6 -0
- mcp_server_kit-0.1.0/.python-version +1 -0
- mcp_server_kit-0.1.0/LICENSE +21 -0
- mcp_server_kit-0.1.0/PKG-INFO +107 -0
- mcp_server_kit-0.1.0/README.md +74 -0
- mcp_server_kit-0.1.0/pyproject.toml +44 -0
- mcp_server_kit-0.1.0/src/mcp_server_kit/__init__.py +9 -0
- mcp_server_kit-0.1.0/src/mcp_server_kit/auth.py +48 -0
- mcp_server_kit-0.1.0/src/mcp_server_kit/files.py +62 -0
- mcp_server_kit-0.1.0/src/mcp_server_kit/healthcheck.py +29 -0
- mcp_server_kit-0.1.0/src/mcp_server_kit/pdf.py +557 -0
- mcp_server_kit-0.1.0/src/mcp_server_kit/serve.py +31 -0
- mcp_server_kit-0.1.0/src/mcp_server_kit/tools.py +121 -0
- mcp_server_kit-0.1.0/tests/fixtures/mixed.pdf +98 -0
- mcp_server_kit-0.1.0/tests/fixtures/sample.pdf +87 -0
- mcp_server_kit-0.1.0/tests/fixtures/scanned.pdf +109 -0
- mcp_server_kit-0.1.0/tests/test_auth_serve.py +132 -0
- mcp_server_kit-0.1.0/tests/test_files.py +76 -0
- mcp_server_kit-0.1.0/tests/test_pdf.py +330 -0
- mcp_server_kit-0.1.0/tests/test_tools.py +191 -0
- mcp_server_kit-0.1.0/uv.lock +1129 -0
|
@@ -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 @@
|
|
|
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)
|