mcp-server-kit 0.1.0__py3-none-any.whl
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/__init__.py +9 -0
- mcp_server_kit/auth.py +48 -0
- mcp_server_kit/files.py +62 -0
- mcp_server_kit/healthcheck.py +29 -0
- mcp_server_kit/pdf.py +557 -0
- mcp_server_kit/serve.py +31 -0
- mcp_server_kit/tools.py +121 -0
- mcp_server_kit-0.1.0.dist-info/METADATA +107 -0
- mcp_server_kit-0.1.0.dist-info/RECORD +11 -0
- mcp_server_kit-0.1.0.dist-info/WHEEL +4 -0
- mcp_server_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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"
|
mcp_server_kit/auth.py
ADDED
|
@@ -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})
|
mcp_server_kit/files.py
ADDED
|
@@ -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)
|
mcp_server_kit/pdf.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""PDF reading helpers: text extraction, xberg OCR, page render.
|
|
2
|
+
|
|
3
|
+
Install with the extra: `mcp-server-kit[pdf]` (pdfplumber, pypdfium2, pillow, httpx).
|
|
4
|
+
Those are imported lazily inside the functions, so importing this module is cheap and
|
|
5
|
+
a server only pays for them on its first PDF call.
|
|
6
|
+
|
|
7
|
+
Reading tiers
|
|
8
|
+
A. Native text — pdfplumber (pdfminer.six) text / layout / tables.
|
|
9
|
+
B. OCR — pages rasterised at 2x via pypdfium2 and sent to an
|
|
10
|
+
xberg server (https://github.com/xberg-io/xberg — POST /extract,
|
|
11
|
+
markdown out). Default backend
|
|
12
|
+
tesseract: xberg's paddle-ocr (the PP-OCR family the pre-2026-08
|
|
13
|
+
in-process RapidOCR used) takes 4-5+ MINUTES on a dense text page
|
|
14
|
+
on CPU in either model tier (measured 2026-08-03) vs ~3 s for
|
|
15
|
+
tesseract — set XBERG_OCR_BACKEND=paddle-ocr only if you can eat
|
|
16
|
+
that latency; use ocr='llm' for hard documents instead.
|
|
17
|
+
C. VLM OCR — same xberg call with the ``vlm`` backend: xberg forwards
|
|
18
|
+
page images to an OpenAI-compatible vision endpoint (e.g. a
|
|
19
|
+
LiteLLM gateway). The gateway key rides in the per-request vlm_config —
|
|
20
|
+
xberg 1.0.8 cannot resolve provider env keys once base_url is
|
|
21
|
+
overridden per request (verified 2026-08-03; revisit if upstream
|
|
22
|
+
fixes env-key resolution, the server-side key was the preferred
|
|
23
|
+
design). Best quality on handwriting / messy tables.
|
|
24
|
+
|
|
25
|
+
The ``ocr`` parameter of :func:`extract_pdf_text` selects the tier:
|
|
26
|
+
'auto' (default) — tier A, then tier B for requested pages whose native
|
|
27
|
+
text came back < OCR_AUTO_MIN_CHARS non-whitespace chars.
|
|
28
|
+
'off' — tier A only.
|
|
29
|
+
'force' — tier B on every requested page (skips tier A).
|
|
30
|
+
'llm' — tier C on every requested page (skips tier A).
|
|
31
|
+
|
|
32
|
+
Env: XBERG_BASE_URL (default http://xberg:8000 — point it at your xberg
|
|
33
|
+
server; if none is reachable, auto-tier OCR degrades to native text with
|
|
34
|
+
an ocr_error note), XBERG_TIMEOUT (s, default 120), XBERG_OCR_BACKEND (default 'tesseract'),
|
|
35
|
+
XBERG_VLM_MODEL (default 'cheap' — e.g. a LiteLLM gateway alias,
|
|
36
|
+
passed verbatim),
|
|
37
|
+
XBERG_VLM_BASE_URL (default http://litellm:4000/v1 — an
|
|
38
|
+
OpenAI-compatible endpoint, resolved by the xberg SERVER, not this
|
|
39
|
+
process), XBERG_VLM_API_KEY (required for ocr='llm' only — the
|
|
40
|
+
LiteLLM virtual key).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import contextlib
|
|
44
|
+
import io
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import os
|
|
48
|
+
import threading
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
VALID_MODES = ("text", "layout", "tables")
|
|
53
|
+
VALID_OCR = ("auto", "off", "force", "llm")
|
|
54
|
+
|
|
55
|
+
# Below this many non-whitespace chars of native text, a page is treated as
|
|
56
|
+
# image-only and OCR'd in 'auto' mode. Real text pages are hundreds of chars;
|
|
57
|
+
# scans typically yield 0 (or a few stray glyphs from a vector letterhead).
|
|
58
|
+
OCR_AUTO_MIN_CHARS = 20
|
|
59
|
+
|
|
60
|
+
# 72 dpi * 2 = 144 dpi input to the OCR engine — the PP-OCR sweet spot for
|
|
61
|
+
# ordinary print sizes; higher scales cost quadratic memory for no gain.
|
|
62
|
+
OCR_RENDER_SCALE = 2.0
|
|
63
|
+
|
|
64
|
+
XBERG_BASE_URL_DEFAULT = "http://xberg:8000"
|
|
65
|
+
XBERG_TIMEOUT_DEFAULT = 120.0
|
|
66
|
+
XBERG_OCR_BACKEND_DEFAULT = "tesseract"
|
|
67
|
+
XBERG_VLM_MODEL_DEFAULT = "cheap"
|
|
68
|
+
XBERG_VLM_BASE_URL_DEFAULT = "http://litellm:4000/v1"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def validate_options(mode: str, ocr: str) -> tuple[str, str]:
|
|
72
|
+
"""Normalise and validate the ``mode`` / ``ocr`` options, raising
|
|
73
|
+
ValueError on anything unknown. Exposed separately so tool wrappers can
|
|
74
|
+
fail fast on bad parameters BEFORE fetching the document bytes."""
|
|
75
|
+
mode_norm = (mode or "text").strip().lower()
|
|
76
|
+
if mode_norm not in VALID_MODES:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"mode must be one of 'text', 'layout', 'tables' — got {mode!r}."
|
|
79
|
+
)
|
|
80
|
+
ocr_norm = (ocr or "auto").strip().lower()
|
|
81
|
+
if ocr_norm not in VALID_OCR:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"ocr must be one of 'auto', 'off', 'force', 'llm' — got {ocr!r}."
|
|
84
|
+
)
|
|
85
|
+
return mode_norm, ocr_norm
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PageOutOfRangeError(ValueError):
|
|
89
|
+
"""Requested page exceeds the document's page count."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, page: int, page_count: int):
|
|
92
|
+
self.page = page
|
|
93
|
+
self.page_count = page_count
|
|
94
|
+
super().__init__(
|
|
95
|
+
f"page {page} out of range (document has {page_count} pages)."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_page_spec(spec: str | None, page_count: int) -> list[int]:
|
|
100
|
+
"""Parse a ``pdftotext``-style page range string into a sorted list of
|
|
101
|
+
1-indexed page numbers, clamped to ``1..page_count``.
|
|
102
|
+
|
|
103
|
+
Accepts ``None`` / ``""`` (all pages), a single page (``"3"``), a range
|
|
104
|
+
(``"1-5"``), a comma-separated list (``"1,3,5"``), or a mix
|
|
105
|
+
(``"1-3,7,10-12"``). Whitespace is tolerated. Pages outside
|
|
106
|
+
``1..page_count`` are silently dropped — callers report the effective
|
|
107
|
+
set via ``pages_returned`` in the response."""
|
|
108
|
+
if spec is None or not str(spec).strip():
|
|
109
|
+
return list(range(1, page_count + 1))
|
|
110
|
+
wanted: set[int] = set()
|
|
111
|
+
for chunk in str(spec).split(","):
|
|
112
|
+
chunk = chunk.strip()
|
|
113
|
+
if not chunk:
|
|
114
|
+
continue
|
|
115
|
+
if "-" in chunk:
|
|
116
|
+
lo_s, _, hi_s = chunk.partition("-")
|
|
117
|
+
try:
|
|
118
|
+
lo, hi = int(lo_s), int(hi_s)
|
|
119
|
+
except ValueError as e:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Invalid page range '{chunk}' in pages={spec!r}."
|
|
122
|
+
) from e
|
|
123
|
+
if lo > hi:
|
|
124
|
+
lo, hi = hi, lo
|
|
125
|
+
for p in range(lo, hi + 1):
|
|
126
|
+
if 1 <= p <= page_count:
|
|
127
|
+
wanted.add(p)
|
|
128
|
+
else:
|
|
129
|
+
try:
|
|
130
|
+
p = int(chunk)
|
|
131
|
+
except ValueError as e:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"Invalid page number '{chunk}' in pages={spec!r}."
|
|
134
|
+
) from e
|
|
135
|
+
if 1 <= p <= page_count:
|
|
136
|
+
wanted.add(p)
|
|
137
|
+
return sorted(wanted)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- rendering --------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _null_or(concurrency: threading.Semaphore | None):
|
|
144
|
+
return concurrency if concurrency is not None else contextlib.nullcontext()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _render_page_pil(pdf, page_index: int, scale: float):
|
|
148
|
+
"""Render one pypdfium2 page to a PIL image with explicit native-memory
|
|
149
|
+
cleanup. pypdfium2 holds PDFium buffers the GC reclaims only
|
|
150
|
+
non-deterministically — close page and bitmap explicitly so a multi-page
|
|
151
|
+
OCR sweep can't pile up native allocations. The returned PIL image owns
|
|
152
|
+
a Python-side copy (``.convert()`` detaches from the bitmap buffer)."""
|
|
153
|
+
page = pdf[page_index]
|
|
154
|
+
try:
|
|
155
|
+
bitmap = page.render(scale=scale)
|
|
156
|
+
try:
|
|
157
|
+
return bitmap.to_pil().convert("RGB")
|
|
158
|
+
finally:
|
|
159
|
+
bitmap.close()
|
|
160
|
+
finally:
|
|
161
|
+
page.close()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render_pdf_page_bytes(
|
|
165
|
+
file_bytes: bytes,
|
|
166
|
+
page: int,
|
|
167
|
+
max_width: int = 1200,
|
|
168
|
+
fmt: str = "jpeg",
|
|
169
|
+
quality: int = 75,
|
|
170
|
+
concurrency: threading.Semaphore | None = None,
|
|
171
|
+
) -> tuple[bytes, str, int, int, int]:
|
|
172
|
+
"""Render one 1-indexed PDF page to encoded image bytes.
|
|
173
|
+
|
|
174
|
+
Returns ``(image_bytes, mime_type, width, height, page_count)``.
|
|
175
|
+
Raises :class:`PageOutOfRangeError` when ``page > page_count``."""
|
|
176
|
+
import pypdfium2 # type: ignore
|
|
177
|
+
from PIL import Image # type: ignore
|
|
178
|
+
|
|
179
|
+
buf = io.BytesIO()
|
|
180
|
+
with _null_or(concurrency):
|
|
181
|
+
pdf = pypdfium2.PdfDocument(io.BytesIO(file_bytes))
|
|
182
|
+
try:
|
|
183
|
+
page_count = len(pdf)
|
|
184
|
+
if page > page_count:
|
|
185
|
+
raise PageOutOfRangeError(page, page_count)
|
|
186
|
+
pdf_page = pdf[page - 1]
|
|
187
|
+
try:
|
|
188
|
+
# pypdfium2 renders at 72 DPI * scale. Pick the scale that
|
|
189
|
+
# makes the output width land near max_width.
|
|
190
|
+
native_w, _native_h = pdf_page.get_size()
|
|
191
|
+
if native_w <= 0:
|
|
192
|
+
native_w = 612.0 # US Letter fallback
|
|
193
|
+
scale = max_width / float(native_w)
|
|
194
|
+
if scale <= 0:
|
|
195
|
+
scale = 1.0
|
|
196
|
+
bitmap = pdf_page.render(scale=scale)
|
|
197
|
+
try:
|
|
198
|
+
pil = bitmap.to_pil()
|
|
199
|
+
# PDFium can overshoot max_width by a pixel — downscale
|
|
200
|
+
# rather than ship a larger image than asked.
|
|
201
|
+
if pil.width > max_width:
|
|
202
|
+
ratio = max_width / pil.width
|
|
203
|
+
pil = pil.resize(
|
|
204
|
+
(max_width, max(1, int(pil.height * ratio))),
|
|
205
|
+
Image.Resampling.LANCZOS,
|
|
206
|
+
)
|
|
207
|
+
if fmt == "jpeg":
|
|
208
|
+
# PDFium renders with alpha; JPEG can't hold one.
|
|
209
|
+
if pil.mode != "RGB":
|
|
210
|
+
pil = pil.convert("RGB")
|
|
211
|
+
pil.save(
|
|
212
|
+
buf, format="JPEG", quality=quality, optimize=True,
|
|
213
|
+
)
|
|
214
|
+
out_mime = "image/jpeg"
|
|
215
|
+
else:
|
|
216
|
+
pil.save(buf, format="PNG", optimize=True)
|
|
217
|
+
out_mime = "image/png"
|
|
218
|
+
out_w, out_h = pil.width, pil.height
|
|
219
|
+
finally:
|
|
220
|
+
bitmap.close()
|
|
221
|
+
finally:
|
|
222
|
+
pdf_page.close()
|
|
223
|
+
finally:
|
|
224
|
+
pdf.close()
|
|
225
|
+
return buf.getvalue(), out_mime, out_w, out_h, page_count
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _render_pages_png(
|
|
229
|
+
file_bytes: bytes,
|
|
230
|
+
page_numbers: list[int],
|
|
231
|
+
scale: float,
|
|
232
|
+
concurrency: threading.Semaphore | None,
|
|
233
|
+
) -> dict[int, bytes]:
|
|
234
|
+
"""Rasterise the given 1-indexed pages to PNG bytes at ``scale``."""
|
|
235
|
+
import pypdfium2 # type: ignore
|
|
236
|
+
|
|
237
|
+
out: dict[int, bytes] = {}
|
|
238
|
+
with _null_or(concurrency):
|
|
239
|
+
pdf = pypdfium2.PdfDocument(io.BytesIO(file_bytes))
|
|
240
|
+
try:
|
|
241
|
+
for p in page_numbers:
|
|
242
|
+
pil = _render_page_pil(pdf, p - 1, scale)
|
|
243
|
+
buf = io.BytesIO()
|
|
244
|
+
pil.save(buf, format="PNG")
|
|
245
|
+
out[p] = buf.getvalue()
|
|
246
|
+
finally:
|
|
247
|
+
pdf.close()
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _pdf_page_count(file_bytes: bytes) -> int:
|
|
252
|
+
import pypdfium2 # type: ignore
|
|
253
|
+
|
|
254
|
+
pdf = pypdfium2.PdfDocument(io.BytesIO(file_bytes))
|
|
255
|
+
try:
|
|
256
|
+
return len(pdf)
|
|
257
|
+
finally:
|
|
258
|
+
pdf.close()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# --- xberg OCR (tiers B and C) ----------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _xberg_config() -> tuple[str, float]:
|
|
265
|
+
base_url = (
|
|
266
|
+
os.environ.get("XBERG_BASE_URL", "").strip().rstrip("/")
|
|
267
|
+
or XBERG_BASE_URL_DEFAULT
|
|
268
|
+
)
|
|
269
|
+
timeout = float(
|
|
270
|
+
os.environ.get("XBERG_TIMEOUT", "").strip() or XBERG_TIMEOUT_DEFAULT
|
|
271
|
+
)
|
|
272
|
+
return base_url, timeout
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def xberg_ocr_pages(
|
|
276
|
+
file_bytes: bytes,
|
|
277
|
+
page_numbers: list[int],
|
|
278
|
+
*,
|
|
279
|
+
vlm: bool = False,
|
|
280
|
+
concurrency: threading.Semaphore | None = None,
|
|
281
|
+
) -> tuple[dict[int, str], str]:
|
|
282
|
+
"""OCR the given 1-indexed pages via the configured xberg server.
|
|
283
|
+
|
|
284
|
+
Pages are rasterised locally (PNG at OCR_RENDER_SCALE) and uploaded in
|
|
285
|
+
ONE multipart request — xberg's ``results`` array maps 1:1 to the
|
|
286
|
+
uploaded files, which keeps the per-page mapping trivial and sidesteps
|
|
287
|
+
any question of how xberg splits a multi-page PDF. ``vlm=True`` selects
|
|
288
|
+
xberg's vision-model backend (tier C); the provider key lives in
|
|
289
|
+
xberg's own env, never in this process.
|
|
290
|
+
|
|
291
|
+
Returns ``({page: markdown_text}, engine_label)``. Raises RuntimeError
|
|
292
|
+
with xberg's status/body on failure so the caller sees exactly what
|
|
293
|
+
the service said."""
|
|
294
|
+
import httpx # type: ignore
|
|
295
|
+
|
|
296
|
+
base_url, timeout = _xberg_config()
|
|
297
|
+
if vlm:
|
|
298
|
+
model = (
|
|
299
|
+
os.environ.get("XBERG_VLM_MODEL", "").strip()
|
|
300
|
+
or XBERG_VLM_MODEL_DEFAULT
|
|
301
|
+
)
|
|
302
|
+
vlm_base = (
|
|
303
|
+
os.environ.get("XBERG_VLM_BASE_URL", "").strip().rstrip("/")
|
|
304
|
+
or XBERG_VLM_BASE_URL_DEFAULT
|
|
305
|
+
)
|
|
306
|
+
api_key = os.environ.get("XBERG_VLM_API_KEY", "").strip()
|
|
307
|
+
if not api_key:
|
|
308
|
+
raise RuntimeError(
|
|
309
|
+
"ocr='llm' requires XBERG_VLM_API_KEY in the server env "
|
|
310
|
+
"(the LiteLLM virtual key; xberg 1.0.8 cannot resolve "
|
|
311
|
+
"provider env keys when base_url is overridden per "
|
|
312
|
+
"request). Use ocr='force' for classical OCR instead."
|
|
313
|
+
)
|
|
314
|
+
config = {
|
|
315
|
+
"ocr": {
|
|
316
|
+
"backend": "vlm",
|
|
317
|
+
"vlm_config": {
|
|
318
|
+
"model": model,
|
|
319
|
+
"base_url": vlm_base,
|
|
320
|
+
"api_key": api_key,
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
engine = f"xberg:vlm:{model}"
|
|
325
|
+
else:
|
|
326
|
+
backend = (
|
|
327
|
+
os.environ.get("XBERG_OCR_BACKEND", "").strip()
|
|
328
|
+
or XBERG_OCR_BACKEND_DEFAULT
|
|
329
|
+
)
|
|
330
|
+
# Language codes are backend-specific: tesseract wants ISO 639-2
|
|
331
|
+
# ("eng"), the paddle/candle family wants ISO 639-1 ("en").
|
|
332
|
+
language = "eng" if backend == "tesseract" else "en"
|
|
333
|
+
config = {"ocr": {"backend": backend, "language": language}}
|
|
334
|
+
engine = f"xberg:{backend}"
|
|
335
|
+
|
|
336
|
+
rendered = _render_pages_png(
|
|
337
|
+
file_bytes, page_numbers, OCR_RENDER_SCALE, concurrency,
|
|
338
|
+
)
|
|
339
|
+
ordered = sorted(rendered.keys())
|
|
340
|
+
files = [
|
|
341
|
+
("files", (f"page-{p}.png", rendered[p], "image/png"))
|
|
342
|
+
for p in ordered
|
|
343
|
+
]
|
|
344
|
+
data = {"output_format": "markdown", "config": json.dumps(config)}
|
|
345
|
+
|
|
346
|
+
with httpx.Client(timeout=timeout) as http:
|
|
347
|
+
resp = http.post(f"{base_url}/extract", data=data, files=files)
|
|
348
|
+
if resp.status_code != 200:
|
|
349
|
+
raise RuntimeError(
|
|
350
|
+
f"xberg OCR request for pages {ordered} failed: HTTP "
|
|
351
|
+
f"{resp.status_code} from {base_url}: {resp.text[:500]}"
|
|
352
|
+
)
|
|
353
|
+
try:
|
|
354
|
+
payload = resp.json()
|
|
355
|
+
errors = payload.get("summary", {}).get("errors", 0)
|
|
356
|
+
results = payload["results"]
|
|
357
|
+
except (KeyError, ValueError) as e:
|
|
358
|
+
raise RuntimeError(
|
|
359
|
+
f"xberg OCR response had an unexpected shape: {resp.text[:500]}"
|
|
360
|
+
) from e
|
|
361
|
+
if errors:
|
|
362
|
+
raise RuntimeError(
|
|
363
|
+
f"xberg reported {errors} extraction error(s) for pages "
|
|
364
|
+
f"{ordered}: {resp.text[:500]}"
|
|
365
|
+
)
|
|
366
|
+
if len(results) != len(ordered):
|
|
367
|
+
raise RuntimeError(
|
|
368
|
+
f"xberg returned {len(results)} results for {len(ordered)} "
|
|
369
|
+
f"uploaded pages — refusing to guess the page mapping."
|
|
370
|
+
)
|
|
371
|
+
out = {
|
|
372
|
+
p: (results[i].get("content") or "").strip()
|
|
373
|
+
for i, p in enumerate(ordered)
|
|
374
|
+
}
|
|
375
|
+
return out, engine
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# --- assembly ----------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def assemble_pages(
|
|
382
|
+
per_page_text: dict[int, str], max_bytes: int,
|
|
383
|
+
) -> tuple[str, list[int], bool]:
|
|
384
|
+
"""Join per-page text under a byte ceiling. Walk pages in order and stop
|
|
385
|
+
as soon as appending the next page would exceed the ceiling. If the
|
|
386
|
+
first page on its own is already over the ceiling, include it but
|
|
387
|
+
truncate mid-text so the caller at least gets something.
|
|
388
|
+
|
|
389
|
+
Returns ``(text, pages_included, truncated)`` — the truncation marker
|
|
390
|
+
telling the caller which pages to fetch next is appended to ``text``."""
|
|
391
|
+
targets_in_order = sorted(per_page_text.keys())
|
|
392
|
+
pieces: list[str] = []
|
|
393
|
+
pages_included: list[int] = []
|
|
394
|
+
running_bytes = 0
|
|
395
|
+
truncated = False
|
|
396
|
+
truncation_note: str | None = None
|
|
397
|
+
for p in targets_in_order:
|
|
398
|
+
body = per_page_text[p]
|
|
399
|
+
chunk = ("\f" if pieces else "") + body
|
|
400
|
+
encoded = chunk.encode("utf-8", errors="replace")
|
|
401
|
+
if running_bytes + len(encoded) > max_bytes:
|
|
402
|
+
included_p = False
|
|
403
|
+
if not pieces:
|
|
404
|
+
# Single page is already too big — truncate it.
|
|
405
|
+
slack = max_bytes - running_bytes
|
|
406
|
+
body_enc = body.encode("utf-8", errors="replace")[:slack]
|
|
407
|
+
pieces.append(body_enc.decode("utf-8", errors="replace"))
|
|
408
|
+
pages_included.append(p)
|
|
409
|
+
running_bytes += len(body_enc)
|
|
410
|
+
included_p = True
|
|
411
|
+
truncated = True
|
|
412
|
+
# The overflowing page p was dropped unless it was force-included
|
|
413
|
+
# above — the retry hint must cover it in that case (the original
|
|
414
|
+
# original implementation skipped it: off-by-one).
|
|
415
|
+
remaining = [
|
|
416
|
+
q for q in targets_in_order
|
|
417
|
+
if (q > p if included_p else q >= p)
|
|
418
|
+
]
|
|
419
|
+
if remaining:
|
|
420
|
+
lo, hi = remaining[0], remaining[-1]
|
|
421
|
+
truncation_note = (
|
|
422
|
+
f"...[truncated at page {pages_included[-1]}, "
|
|
423
|
+
f"call again with pages={lo}-{hi}]"
|
|
424
|
+
)
|
|
425
|
+
else:
|
|
426
|
+
truncation_note = (
|
|
427
|
+
f"...[truncated mid-page {pages_included[-1]}, "
|
|
428
|
+
f"call again with pages={pages_included[-1]} and "
|
|
429
|
+
f"a narrower mode]"
|
|
430
|
+
)
|
|
431
|
+
break
|
|
432
|
+
pieces.append(chunk)
|
|
433
|
+
pages_included.append(p)
|
|
434
|
+
running_bytes += len(encoded)
|
|
435
|
+
|
|
436
|
+
text_out = "".join(pieces)
|
|
437
|
+
if truncation_note:
|
|
438
|
+
text_out += "\n" + truncation_note
|
|
439
|
+
return text_out, pages_included, truncated
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# --- top-level extraction ------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def extract_pdf_text(
|
|
446
|
+
file_bytes: bytes,
|
|
447
|
+
pages: str | None = None,
|
|
448
|
+
mode: str = "text",
|
|
449
|
+
ocr: str = "auto",
|
|
450
|
+
*,
|
|
451
|
+
max_bytes: int = 200_000,
|
|
452
|
+
concurrency: threading.Semaphore | None = None,
|
|
453
|
+
) -> dict:
|
|
454
|
+
"""Extract text from PDF bytes. Returns a dict with keys ``text``,
|
|
455
|
+
``page_count``, ``pages_returned``, ``mode``, ``truncated``, plus
|
|
456
|
+
``ocr_used`` / ``ocr_pages`` / ``ocr_engine`` when OCR ran (and
|
|
457
|
+
``ocr_error`` when 'auto'-mode OCR was needed but failed — the caller
|
|
458
|
+
then still gets the native-text result instead of an exception).
|
|
459
|
+
|
|
460
|
+
``mode`` applies to native extraction only; OCR'd pages come back as
|
|
461
|
+
markdown-flavoured text from xberg (both tiers)."""
|
|
462
|
+
mode_norm, ocr_norm = validate_options(mode, ocr)
|
|
463
|
+
|
|
464
|
+
per_page_text: dict[int, str] = {}
|
|
465
|
+
|
|
466
|
+
if ocr_norm in ("force", "llm"):
|
|
467
|
+
# Native extraction would be discarded — only the page count is
|
|
468
|
+
# needed to resolve the page spec.
|
|
469
|
+
page_count = _pdf_page_count(file_bytes)
|
|
470
|
+
targets = parse_page_spec(pages, page_count)
|
|
471
|
+
per_page_text = {p: "" for p in targets}
|
|
472
|
+
else:
|
|
473
|
+
import pdfplumber # type: ignore
|
|
474
|
+
|
|
475
|
+
with _null_or(concurrency), pdfplumber.open(
|
|
476
|
+
io.BytesIO(file_bytes)
|
|
477
|
+
) as pdf:
|
|
478
|
+
page_count = len(pdf.pages)
|
|
479
|
+
targets = parse_page_spec(pages, page_count)
|
|
480
|
+
for p in targets:
|
|
481
|
+
page = pdf.pages[p - 1]
|
|
482
|
+
if mode_norm == "text":
|
|
483
|
+
per_page_text[p] = page.extract_text() or ""
|
|
484
|
+
elif mode_norm == "layout":
|
|
485
|
+
per_page_text[p] = page.extract_text(layout=True) or ""
|
|
486
|
+
else: # tables
|
|
487
|
+
tables = page.extract_tables() or []
|
|
488
|
+
if not tables:
|
|
489
|
+
per_page_text[p] = ""
|
|
490
|
+
continue
|
|
491
|
+
parts: list[str] = []
|
|
492
|
+
for ti, table in enumerate(tables, 1):
|
|
493
|
+
parts.append(f"## Page {p} Table {ti}")
|
|
494
|
+
for row in table:
|
|
495
|
+
cells = [
|
|
496
|
+
("" if c is None else str(c)).replace(
|
|
497
|
+
"|", "\\|"
|
|
498
|
+
)
|
|
499
|
+
for c in row
|
|
500
|
+
]
|
|
501
|
+
parts.append("| " + " | ".join(cells) + " |")
|
|
502
|
+
parts.append("")
|
|
503
|
+
per_page_text[p] = "\n".join(parts)
|
|
504
|
+
|
|
505
|
+
# Pick the pages OCR should cover.
|
|
506
|
+
if ocr_norm == "off":
|
|
507
|
+
ocr_targets: list[int] = []
|
|
508
|
+
elif ocr_norm == "auto":
|
|
509
|
+
ocr_targets = [
|
|
510
|
+
p
|
|
511
|
+
for p in sorted(per_page_text.keys())
|
|
512
|
+
if len(per_page_text[p].strip()) < OCR_AUTO_MIN_CHARS
|
|
513
|
+
]
|
|
514
|
+
else: # force / llm
|
|
515
|
+
ocr_targets = sorted(per_page_text.keys())
|
|
516
|
+
|
|
517
|
+
ocr_used = False
|
|
518
|
+
ocr_engine: str | None = None
|
|
519
|
+
ocr_error: str | None = None
|
|
520
|
+
if ocr_targets:
|
|
521
|
+
try:
|
|
522
|
+
ocr_text, ocr_engine = xberg_ocr_pages(
|
|
523
|
+
file_bytes,
|
|
524
|
+
ocr_targets,
|
|
525
|
+
vlm=(ocr_norm == "llm"),
|
|
526
|
+
concurrency=concurrency,
|
|
527
|
+
)
|
|
528
|
+
per_page_text.update(ocr_text)
|
|
529
|
+
ocr_used = True
|
|
530
|
+
except Exception as e:
|
|
531
|
+
# 'auto' is best-effort: a broken OCR stack must not take down
|
|
532
|
+
# plain text extraction — surface the failure in the result
|
|
533
|
+
# instead (the silent-failure trap this module replaces).
|
|
534
|
+
# An explicit 'force'/'llm' request propagates.
|
|
535
|
+
if ocr_norm != "auto":
|
|
536
|
+
raise
|
|
537
|
+
logger.warning("auto-mode OCR failed: %s", e)
|
|
538
|
+
ocr_error = f"{type(e).__name__}: {e}"
|
|
539
|
+
|
|
540
|
+
text_out, pages_included, truncated = assemble_pages(
|
|
541
|
+
per_page_text, max_bytes,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
result = {
|
|
545
|
+
"text": text_out,
|
|
546
|
+
"page_count": page_count,
|
|
547
|
+
"pages_returned": pages_included,
|
|
548
|
+
"mode": mode_norm,
|
|
549
|
+
"truncated": truncated,
|
|
550
|
+
}
|
|
551
|
+
if ocr_used:
|
|
552
|
+
result["ocr_used"] = True
|
|
553
|
+
result["ocr_pages"] = ocr_targets
|
|
554
|
+
result["ocr_engine"] = ocr_engine
|
|
555
|
+
if ocr_error is not None:
|
|
556
|
+
result["ocr_error"] = ocr_error
|
|
557
|
+
return result
|
mcp_server_kit/serve.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Run a FastMCP server over stdio or bearer-gated Streamable HTTP."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from mcp_server_kit.auth import BearerAuthMiddleware
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def serve(mcp: FastMCP, *, port: int, bearer_token: str, host: str = "0.0.0.0",
|
|
11
|
+
argv: list[str] | None = None) -> None:
|
|
12
|
+
"""`--stdio` in argv runs stdio (the client owns the process, so no auth). Otherwise
|
|
13
|
+
Streamable HTTP at /mcp behind BearerAuthMiddleware, and it refuses to start without
|
|
14
|
+
a token rather than serve unauthenticated.
|
|
15
|
+
|
|
16
|
+
Build the FastMCP with stateless_http=True, json_response=True, so a dropped
|
|
17
|
+
connection or a restart never strands a client session.
|
|
18
|
+
"""
|
|
19
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
20
|
+
if "--stdio" in argv:
|
|
21
|
+
mcp.run(transport="stdio")
|
|
22
|
+
return
|
|
23
|
+
if not bearer_token:
|
|
24
|
+
raise SystemExit(
|
|
25
|
+
"MCP_BEARER_TOKEN is not set. Refusing to start an unauthenticated "
|
|
26
|
+
"MCP server. Set it in .secrets.env and restart."
|
|
27
|
+
)
|
|
28
|
+
import uvicorn
|
|
29
|
+
|
|
30
|
+
app = BearerAuthMiddleware(mcp.streamable_http_app(), expected_token=bearer_token)
|
|
31
|
+
uvicorn.run(app, host=host, port=port)
|
mcp_server_kit/tools.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Tool registration: run sync handlers off the event loop, log one line per call.
|
|
2
|
+
|
|
3
|
+
The MCP SDK runs a synchronous tool handler inline on the single asyncio event
|
|
4
|
+
loop, so one blocking upstream call (httpx, httplib2, time.sleep) stalls every
|
|
5
|
+
other request until it returns. `offload_sync_tools` rewires `mcp.tool` so each
|
|
6
|
+
sync handler is registered as an async wrapper that runs the body in a worker
|
|
7
|
+
thread. The tool's signature is preserved, so its input schema is unchanged.
|
|
8
|
+
The decorator returns the original function, so module-level names stay
|
|
9
|
+
directly callable (tool-to-tool calls, unit tests).
|
|
10
|
+
|
|
11
|
+
Every call, sync or async, logs one line in the format audit tooling parses:
|
|
12
|
+
|
|
13
|
+
<asctime> INFO tool=<name> outcome=ok|error duration_ms=<n> rss_mib=<n>
|
|
14
|
+
|
|
15
|
+
Guard non-thread-safe shared clients (e.g. cached httplib2 objects) with a lock
|
|
16
|
+
in the server; thread-safe ones (httpx.Client, requests.Session) need none.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import inspect
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import anyio
|
|
27
|
+
from mcp.server.fastmcp import FastMCP
|
|
28
|
+
|
|
29
|
+
_LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rss_mib() -> int:
|
|
33
|
+
"""Current process RSS in MiB from /proc (Linux), or -1 where that isn't available."""
|
|
34
|
+
try:
|
|
35
|
+
with open("/proc/self/statm") as f:
|
|
36
|
+
pages = int(f.read().split()[1])
|
|
37
|
+
return pages * os.sysconf("SC_PAGE_SIZE") // (1024 * 1024)
|
|
38
|
+
except (OSError, ValueError, IndexError):
|
|
39
|
+
return -1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def request_logger(name: str) -> logging.Logger:
|
|
43
|
+
"""The per-call logger: its own stderr handler, so the line format doesn't depend on
|
|
44
|
+
how (or whether) the server configured root logging. stderr is safe under stdio."""
|
|
45
|
+
log = logging.getLogger(name)
|
|
46
|
+
if not log.handlers:
|
|
47
|
+
handler = logging.StreamHandler()
|
|
48
|
+
handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
|
49
|
+
log.addHandler(handler)
|
|
50
|
+
log.setLevel(logging.INFO)
|
|
51
|
+
log.propagate = False
|
|
52
|
+
return log
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _signature(fn) -> inspect.Signature:
|
|
56
|
+
# Resolve string annotations (`from __future__ import annotations`): FastMCP/pydantic
|
|
57
|
+
# can't resolve forward refs like Optional[str] through the wrapper indirection.
|
|
58
|
+
try:
|
|
59
|
+
return inspect.signature(fn, eval_str=True)
|
|
60
|
+
except Exception: # noqa: BLE001 - unresolvable name: fall back to the raw signature
|
|
61
|
+
return inspect.signature(fn)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def offload_sync_tools(mcp: FastMCP, *, logger_name: str, default_annotations: Any = None) -> None:
|
|
65
|
+
"""Rewire `mcp.tool` (call once, before the first @mcp.tool()).
|
|
66
|
+
|
|
67
|
+
logger_name: the per-call logger, conventionally "<package>.requests".
|
|
68
|
+
default_annotations: ToolAnnotations applied to tools that don't pass their own.
|
|
69
|
+
"""
|
|
70
|
+
register_tool = mcp.tool
|
|
71
|
+
log = request_logger(logger_name)
|
|
72
|
+
|
|
73
|
+
def tool(*d_args, **d_kwargs):
|
|
74
|
+
if default_annotations is not None:
|
|
75
|
+
d_kwargs.setdefault("annotations", default_annotations)
|
|
76
|
+
register = register_tool(*d_args, **d_kwargs)
|
|
77
|
+
|
|
78
|
+
def decorator(fn):
|
|
79
|
+
name = d_kwargs.get("name") or fn.__name__
|
|
80
|
+
|
|
81
|
+
def done(start: float, outcome: str) -> None:
|
|
82
|
+
log.info("tool=%s outcome=%s duration_ms=%d rss_mib=%d",
|
|
83
|
+
name, outcome, int((time.perf_counter() - start) * 1000), rss_mib())
|
|
84
|
+
|
|
85
|
+
if inspect.iscoroutinefunction(fn):
|
|
86
|
+
@functools.wraps(fn)
|
|
87
|
+
async def wrapper(*args, **kwargs):
|
|
88
|
+
start, outcome = time.perf_counter(), "ok"
|
|
89
|
+
try:
|
|
90
|
+
return await fn(*args, **kwargs)
|
|
91
|
+
except Exception:
|
|
92
|
+
outcome = "error"
|
|
93
|
+
raise
|
|
94
|
+
finally:
|
|
95
|
+
done(start, outcome)
|
|
96
|
+
else:
|
|
97
|
+
@functools.wraps(fn)
|
|
98
|
+
async def wrapper(*args, **kwargs):
|
|
99
|
+
start, outcome = time.perf_counter(), "ok"
|
|
100
|
+
try:
|
|
101
|
+
return await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs))
|
|
102
|
+
except Exception:
|
|
103
|
+
outcome = "error"
|
|
104
|
+
raise
|
|
105
|
+
finally:
|
|
106
|
+
done(start, outcome)
|
|
107
|
+
|
|
108
|
+
sig = _signature(fn)
|
|
109
|
+
wrapper.__signature__ = sig
|
|
110
|
+
wrapper.__annotations__ = {
|
|
111
|
+
**{p.name: p.annotation for p in sig.parameters.values()
|
|
112
|
+
if p.annotation is not inspect.Parameter.empty},
|
|
113
|
+
**({"return": sig.return_annotation}
|
|
114
|
+
if sig.return_annotation is not inspect.Signature.empty else {}),
|
|
115
|
+
}
|
|
116
|
+
register(wrapper)
|
|
117
|
+
return fn
|
|
118
|
+
|
|
119
|
+
return decorator
|
|
120
|
+
|
|
121
|
+
mcp.tool = tool
|
|
@@ -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,11 @@
|
|
|
1
|
+
mcp_server_kit/__init__.py,sha256=W6uj-aSN4t5XBIufiucfAU31zAcRKD1GqGv_1KTNmeU,460
|
|
2
|
+
mcp_server_kit/auth.py,sha256=EjYGzINGxDn4MIX1vN8SiijABxpOURQV5SGU2Kwq_qQ,2112
|
|
3
|
+
mcp_server_kit/files.py,sha256=eUYy8JLSrePCnkN6vnrEYmL6YcdkdjbFzDoa19zT2O4,2392
|
|
4
|
+
mcp_server_kit/healthcheck.py,sha256=tB54y7cqSKRn36EEUogc5BYjl1baH90zM-Ufw-_2c6g,1021
|
|
5
|
+
mcp_server_kit/pdf.py,sha256=iXLN5QTrPLkuOx_pj3wYqa5EjF4R4ZziQFBDHVmZgZM,21293
|
|
6
|
+
mcp_server_kit/serve.py,sha256=vhuzGjLR-qpT3JwZ6W98YpKa7h3pMbUpovFMziBwipo,1164
|
|
7
|
+
mcp_server_kit/tools.py,sha256=HhDS7PN6Io1wjIhz5VE_Zf_kyTaFNiP3uW0JHdjRl44,4741
|
|
8
|
+
mcp_server_kit-0.1.0.dist-info/METADATA,sha256=Vg3SC_QiTsOBXCXNvMY63zqo25TA0EJdFskCaNCztqQ,4501
|
|
9
|
+
mcp_server_kit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
mcp_server_kit-0.1.0.dist-info/licenses/LICENSE,sha256=UZKeFpV4SVVpWdGWdfHRZR5YmP82IP35IqH48YQdKLQ,1067
|
|
11
|
+
mcp_server_kit-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|