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.
@@ -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})
@@ -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
@@ -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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.