sourcelock 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.
hc_source/manifest.py ADDED
@@ -0,0 +1,422 @@
1
+ """Signed release manifests: prove *which* pins and vendored data a run used.
2
+
3
+ A manifest is a JSON **envelope** with two top-level keys::
4
+
5
+ {"payload": {...}, "signature": {...} | null}
6
+
7
+ ``payload`` captures the current state of the world as this install sees it:
8
+
9
+ * the lockfile pins (raw sha256 of the file plus the parsed sources), so a
10
+ customer can check the manifest they received against the ``source-lock.json``
11
+ they committed;
12
+ * a sha256 for every vendored data file packaged under ``hc_source/data/``;
13
+ * tool versions (sourcelock itself, python, and the load-bearing libraries);
14
+ * ``signed_at``, written by ``sign_envelope``. It lives in the payload rather
15
+ than beside the signature because a signing time nobody signed is a signing
16
+ time anyone can edit, and "this was signed before the incident" is precisely
17
+ what a manifest gets used to claim.
18
+
19
+ Signing is Ed25519 (via ``cryptography``), computed over the **canonical
20
+ serialization of the payload only**: ``json.dumps(payload, sort_keys=True,
21
+ separators=(",", ":"), ensure_ascii=False).encode("utf-8")``. The envelope file
22
+ itself may be pretty-printed, re-indented, or re-ordered without breaking the
23
+ signature -- only the payload's *content* is covered. The signature block is
24
+ detached in exactly that sense: it lives next to the payload, never inside it.
25
+
26
+ Trust model: ``verify`` trusts ONLY the public key handed to it (``--key``).
27
+ The fingerprint embedded in the signature block identifies which key was used
28
+ (useful during rotation) but is never itself a verification input.
29
+
30
+ Keys are PEM: PKCS8 for the private key, SubjectPublicKeyInfo for the public
31
+ key. ``generate_keypair`` writes the private key ``0600`` and refuses to
32
+ overwrite existing files. Private keys never belong in this repository --
33
+ see the ops runbook for storage and rotation.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import base64
39
+ import hashlib
40
+ import json
41
+ import platform
42
+ from importlib import metadata
43
+ from pathlib import Path
44
+
45
+ from cryptography.exceptions import InvalidSignature
46
+ from cryptography.hazmat.primitives import serialization
47
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
48
+ Ed25519PrivateKey,
49
+ Ed25519PublicKey,
50
+ )
51
+
52
+ from .lockfile import load_lockfile
53
+ from .schemas import utcnow
54
+
55
+ __all__ = [
56
+ "MANIFEST_KIND",
57
+ "MANIFEST_VERSION",
58
+ "ManifestError",
59
+ "SIGNATURE_ALGORITHM",
60
+ "SIGNING_KEY_ENV",
61
+ "build_envelope",
62
+ "build_payload",
63
+ "canonical_payload_bytes",
64
+ "generate_keypair",
65
+ "load_envelope",
66
+ "load_private_key",
67
+ "load_public_key",
68
+ "public_key_fingerprint",
69
+ "save_envelope",
70
+ "sign_envelope",
71
+ "tool_versions",
72
+ "vendored_data_hashes",
73
+ "verify_envelope",
74
+ "verify_lockfile",
75
+ "verify_vendored_data",
76
+ ]
77
+
78
+ MANIFEST_VERSION = 1
79
+ MANIFEST_KIND = "sourcelock-manifest"
80
+ SIGNATURE_ALGORITHM = "Ed25519"
81
+ #: Environment variable naming the private-key path for ``hc-source manifest sign``.
82
+ SIGNING_KEY_ENV = "HC_SOURCE_SIGNING_KEY"
83
+
84
+ #: Distributions whose versions are recorded in every manifest.
85
+ _TRACKED_DISTS = ("sourcelock", "httpx", "typer", "rich", "pydantic", "mcp", "cryptography")
86
+
87
+ _DATA_DIR = Path(__file__).resolve().parent / "data"
88
+
89
+
90
+ class ManifestError(Exception):
91
+ """A manifest envelope, key, or signature is unusable. The message says why."""
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # canonicalization
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def canonical_payload_bytes(payload: dict) -> bytes:
100
+ """The exact bytes a signature covers. Deterministic for equal payloads."""
101
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
102
+ "utf-8"
103
+ )
104
+
105
+
106
+ def _sha256_bytes(data: bytes) -> str:
107
+ return hashlib.sha256(data).hexdigest()
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # payload
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def vendored_data_hashes(data_dir: Path | None = None) -> dict[str, dict]:
116
+ """sha256 + size for every file packaged under ``hc_source/data/``.
117
+
118
+ Keys are POSIX-style paths relative to the data dir, sorted, so the map is
119
+ byte-stable across platforms. ``__pycache__`` droppings are excluded.
120
+ """
121
+ root = Path(data_dir) if data_dir is not None else _DATA_DIR
122
+ if not root.is_dir():
123
+ raise ManifestError(f"vendored data directory not found: {root}")
124
+ out: dict[str, dict] = {}
125
+ for path in sorted(root.rglob("*")):
126
+ if not path.is_file() or "__pycache__" in path.parts:
127
+ continue
128
+ raw = path.read_bytes()
129
+ out[path.relative_to(root).as_posix()] = {
130
+ "sha256": _sha256_bytes(raw),
131
+ "bytes": len(raw),
132
+ }
133
+ return out
134
+
135
+
136
+ def tool_versions() -> dict[str, str]:
137
+ versions: dict[str, str] = {"python": platform.python_version()}
138
+ for dist in _TRACKED_DISTS:
139
+ try:
140
+ versions[dist] = metadata.version(dist)
141
+ except metadata.PackageNotFoundError:
142
+ versions[dist] = "not-installed"
143
+ return versions
144
+
145
+
146
+ def build_payload(lock_path: Path) -> dict:
147
+ """Assemble the manifest payload from a lockfile on disk.
148
+
149
+ Raises ``FileNotFoundError`` (with the fix) when the lockfile is absent and
150
+ ``pydantic.ValidationError`` when it does not parse -- same contract as the
151
+ rest of the CLI.
152
+ """
153
+ lock_path = Path(lock_path)
154
+ lockfile = load_lockfile(lock_path) # validates; raises with remediation
155
+ raw = lock_path.read_bytes()
156
+ return {
157
+ "kind": MANIFEST_KIND,
158
+ "manifest_version": MANIFEST_VERSION,
159
+ "generated_at": utcnow().isoformat(),
160
+ "lockfile": {
161
+ "path": lock_path.name,
162
+ "sha256": _sha256_bytes(raw),
163
+ "generated_at": lockfile.generated_at.isoformat(),
164
+ "sources": {
165
+ sid: entry.model_dump(mode="json") for sid, entry in lockfile.sources.items()
166
+ },
167
+ },
168
+ "vendored_data": vendored_data_hashes(),
169
+ "tool_versions": tool_versions(),
170
+ }
171
+
172
+
173
+ def build_envelope(lock_path: Path) -> dict:
174
+ return {"payload": build_payload(lock_path), "signature": None}
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # envelope io
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ def load_envelope(path: Path) -> dict:
183
+ path = Path(path)
184
+ try:
185
+ raw = path.read_text(encoding="utf-8")
186
+ except OSError as exc:
187
+ raise ManifestError(f"cannot read manifest {path}: {type(exc).__name__}") from exc
188
+ try:
189
+ envelope = json.loads(raw)
190
+ except json.JSONDecodeError as exc:
191
+ raise ManifestError(f"manifest {path} is not valid JSON: {exc}") from exc
192
+
193
+ if not isinstance(envelope, dict) or "payload" not in envelope:
194
+ raise ManifestError(f"manifest {path} is not an envelope: expected a 'payload' key")
195
+ payload = envelope["payload"]
196
+ if not isinstance(payload, dict):
197
+ raise ManifestError(f"manifest {path}: payload must be an object")
198
+ if payload.get("kind") != MANIFEST_KIND:
199
+ raise ManifestError(
200
+ f"manifest {path}: payload.kind is {payload.get('kind')!r}, expected {MANIFEST_KIND!r}"
201
+ )
202
+ if payload.get("manifest_version") != MANIFEST_VERSION:
203
+ raise ManifestError(
204
+ f"manifest {path}: manifest_version {payload.get('manifest_version')!r} "
205
+ f"is not supported (this tool speaks version {MANIFEST_VERSION})"
206
+ )
207
+ envelope.setdefault("signature", None)
208
+ return envelope
209
+
210
+
211
+ def save_envelope(envelope: dict, path: Path) -> Path:
212
+ """Write an envelope pretty-printed. Formatting never affects the signature."""
213
+ path = Path(path)
214
+ path.parent.mkdir(parents=True, exist_ok=True)
215
+ path.write_text(json.dumps(envelope, indent=2, sort_keys=True) + "\n", encoding="utf-8")
216
+ return path
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # keys
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ def load_private_key(path: Path) -> Ed25519PrivateKey:
225
+ path = Path(path)
226
+ try:
227
+ raw = path.read_bytes()
228
+ except OSError as exc:
229
+ raise ManifestError(f"cannot read private key {path}: {type(exc).__name__}") from exc
230
+ try:
231
+ key = serialization.load_pem_private_key(raw, password=None)
232
+ except (ValueError, TypeError) as exc:
233
+ raise ManifestError(
234
+ f"{path} is not a readable unencrypted PEM private key: {exc}"
235
+ ) from exc
236
+ if not isinstance(key, Ed25519PrivateKey):
237
+ raise ManifestError(f"{path} is not an Ed25519 private key ({type(key).__name__})")
238
+ return key
239
+
240
+
241
+ def load_public_key(path: Path) -> Ed25519PublicKey:
242
+ path = Path(path)
243
+ try:
244
+ raw = path.read_bytes()
245
+ except OSError as exc:
246
+ raise ManifestError(f"cannot read public key {path}: {type(exc).__name__}") from exc
247
+ try:
248
+ key = serialization.load_pem_public_key(raw)
249
+ except ValueError as exc:
250
+ raise ManifestError(f"{path} is not a readable PEM public key: {exc}") from exc
251
+ if not isinstance(key, Ed25519PublicKey):
252
+ raise ManifestError(f"{path} is not an Ed25519 public key ({type(key).__name__})")
253
+ return key
254
+
255
+
256
+ def public_key_fingerprint(public_key: Ed25519PublicKey) -> str:
257
+ """sha256 hex over the 32 raw public-key bytes. Identifies a key, proves nothing."""
258
+ raw = public_key.public_bytes(
259
+ encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
260
+ )
261
+ return _sha256_bytes(raw)
262
+
263
+
264
+ def generate_keypair(out_dir: Path, name: str = "manifest-signing") -> tuple[Path, Path]:
265
+ """Write ``<name>.key`` (PKCS8 PEM, mode 0600) and ``<name>.pub`` (PEM).
266
+
267
+ Refuses to overwrite: rotating a key must be an explicit new name, never a
268
+ silent replacement of the file a customer's verify step still trusts.
269
+ """
270
+ out_dir = Path(out_dir)
271
+ out_dir.mkdir(parents=True, exist_ok=True)
272
+ priv_path = out_dir / f"{name}.key"
273
+ pub_path = out_dir / f"{name}.pub"
274
+ for existing in (priv_path, pub_path):
275
+ if existing.exists():
276
+ raise ManifestError(
277
+ f"{existing} already exists; pick a new --name instead of overwriting a key"
278
+ )
279
+
280
+ private_key = Ed25519PrivateKey.generate()
281
+ priv_pem = private_key.private_bytes(
282
+ encoding=serialization.Encoding.PEM,
283
+ format=serialization.PrivateFormat.PKCS8,
284
+ encryption_algorithm=serialization.NoEncryption(),
285
+ )
286
+ pub_pem = private_key.public_key().public_bytes(
287
+ encoding=serialization.Encoding.PEM,
288
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
289
+ )
290
+
291
+ priv_path.touch(mode=0o600)
292
+ priv_path.write_bytes(priv_pem)
293
+ priv_path.chmod(0o600)
294
+ pub_path.write_bytes(pub_pem)
295
+ return priv_path, pub_path
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # sign / verify
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ def sign_envelope(envelope: dict, private_key: Ed25519PrivateKey) -> dict:
304
+ """Return a new envelope whose signature block covers the payload.
305
+
306
+ ``signed_at`` goes INSIDE the payload. It used to sit in the signature block
307
+ beside the signature itself, where nothing covered it: anyone holding a
308
+ signed manifest could edit the timestamp and it still verified. A signing
309
+ time that can be rewritten by the person you are proving something to is not
310
+ evidence, and "this manifest was signed before the incident" is exactly the
311
+ claim a manifest gets used for.
312
+ """
313
+ payload = envelope.get("payload")
314
+ if not isinstance(payload, dict):
315
+ raise ManifestError("envelope has no payload to sign")
316
+ payload = {**payload, "signed_at": utcnow().isoformat()}
317
+ signature = private_key.sign(canonical_payload_bytes(payload))
318
+ return {
319
+ "payload": payload,
320
+ "signature": {
321
+ "algorithm": SIGNATURE_ALGORITHM,
322
+ "public_key_fingerprint": public_key_fingerprint(private_key.public_key()),
323
+ "signature_b64": base64.b64encode(signature).decode("ascii"),
324
+ },
325
+ }
326
+
327
+
328
+ def verify_envelope(envelope: dict, public_key: Ed25519PublicKey) -> None:
329
+ """Raise :class:`ManifestError` unless the signature verifies with this key.
330
+
331
+ The provided key is the only trust anchor. The embedded fingerprint is
332
+ checked first purely to produce a better error message when the manifest
333
+ was signed by a *different* key (e.g. after rotation).
334
+ """
335
+ payload = envelope.get("payload")
336
+ if not isinstance(payload, dict):
337
+ raise ManifestError("envelope has no payload")
338
+ signature = envelope.get("signature")
339
+ if not isinstance(signature, dict):
340
+ raise ManifestError("manifest is unsigned: run `hc-source manifest sign` first")
341
+
342
+ algorithm = signature.get("algorithm")
343
+ if algorithm != SIGNATURE_ALGORITHM:
344
+ raise ManifestError(
345
+ f"unsupported signature algorithm {algorithm!r} (expected {SIGNATURE_ALGORITHM!r})"
346
+ )
347
+
348
+ embedded = signature.get("public_key_fingerprint")
349
+ ours = public_key_fingerprint(public_key)
350
+ if isinstance(embedded, str) and embedded != ours:
351
+ raise ManifestError(
352
+ "manifest was signed by a different key "
353
+ f"(manifest fingerprint {embedded[:16]}..., verifying key {ours[:16]}...); "
354
+ "if the signing key was rotated, fetch the current public key"
355
+ )
356
+
357
+ sig_b64 = signature.get("signature_b64")
358
+ if not isinstance(sig_b64, str):
359
+ raise ManifestError("signature block has no signature_b64")
360
+ try:
361
+ raw_sig = base64.b64decode(sig_b64, validate=True)
362
+ except (ValueError, TypeError) as exc:
363
+ raise ManifestError(f"signature_b64 is not valid base64: {exc}") from exc
364
+
365
+ try:
366
+ public_key.verify(raw_sig, canonical_payload_bytes(payload))
367
+ except InvalidSignature:
368
+ raise ManifestError(
369
+ "signature does NOT verify: the payload was modified after signing, "
370
+ "or it was signed by a different key"
371
+ ) from None
372
+
373
+
374
+ def verify_lockfile(payload: dict, lock_path: Path) -> list[str]:
375
+ """Compare the payload's lockfile hash to the lockfile at ``lock_path``.
376
+
377
+ Without this, a signed manifest proves only that SOME lockfile was signed at
378
+ some point. It says nothing about the ``source-lock.json`` doctor and the
379
+ runtime are actually reading, which is the one a customer cares about --
380
+ "the pins were signed" and "the pins in use were signed" are different
381
+ claims, and only the second is worth anything in an audit.
382
+ """
383
+ recorded = payload.get("lockfile")
384
+ if not isinstance(recorded, dict) or not isinstance(recorded.get("sha256"), str):
385
+ return ["payload has no lockfile hash to compare against"]
386
+ lock_path = Path(lock_path)
387
+ try:
388
+ raw = lock_path.read_bytes()
389
+ except OSError as exc:
390
+ return [f"{lock_path}: cannot read the lockfile ({type(exc).__name__})"]
391
+ local = _sha256_bytes(raw)
392
+ if local != recorded["sha256"]:
393
+ return [
394
+ f"{lock_path}: sha256 {local[:16]}... does not match the signed manifest's "
395
+ f"{recorded['sha256'][:16]}.... The manifest describes a different set of pins "
396
+ "than the ones this install would check against. Re-run `hc-source manifest "
397
+ "build` and re-sign, or find out who changed the lockfile."
398
+ ]
399
+ return []
400
+
401
+
402
+ def verify_vendored_data(payload: dict, data_dir: Path | None = None) -> list[str]:
403
+ """Compare the payload's vendored-data hashes to this install's files.
404
+
405
+ Returns a list of human-readable mismatches (empty means everything
406
+ matches). This answers "is the data I am running the data that was
407
+ signed?", which the signature alone cannot.
408
+ """
409
+ recorded = payload.get("vendored_data")
410
+ if not isinstance(recorded, dict):
411
+ return ["payload has no vendored_data map"]
412
+ local = vendored_data_hashes(data_dir)
413
+ problems: list[str] = []
414
+ for rel, meta in sorted(recorded.items()):
415
+ if rel not in local:
416
+ problems.append(f"{rel}: recorded in manifest but missing from this install")
417
+ continue
418
+ if not isinstance(meta, dict) or local[rel]["sha256"] != meta.get("sha256"):
419
+ problems.append(f"{rel}: sha256 differs from the signed manifest")
420
+ for rel in sorted(set(local) - set(recorded)):
421
+ problems.append(f"{rel}: present in this install but absent from the signed manifest")
422
+ return problems
@@ -0,0 +1,203 @@
1
+ """MCP server mode: ``hc-source mcp``.
2
+
3
+ Every discovered tool is served over MCP stdio with the adapter's own parameter
4
+ schema and the same zero-PHI guard the CLI uses, so an agent cannot reach a
5
+ route by a path that skips the guard.
6
+
7
+ Two notes on the library, both verified against the installed ``mcp`` 2.0.0
8
+ (the version is pinned in pyproject.toml because this module depends on the
9
+ API shape):
10
+
11
+ * As of ``mcp`` 2.0 the FastMCP class is named ``MCPServer``; ``FastMCP`` is no
12
+ longer exported.
13
+ * We build on the low-level ``mcp.server.lowlevel.Server`` rather than
14
+ ``MCPServer`` deliberately. ``MCPServer`` derives each tool's argument model
15
+ from the handler's Python signature and validates against it before the
16
+ handler runs. That has two consequences SourceLock cannot accept: unknown
17
+ arguments are silently dropped -- so a parameter like ``ssn`` never reaches
18
+ the guard and the call succeeds as though it had been filtered -- and its
19
+ validation errors echo the raw input value back to the client, which is the
20
+ one thing the guard exists to prevent. The low-level server hands us the
21
+ arguments untouched, so the guard sees exactly what the client sent.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from typing import Any
28
+
29
+ import anyio
30
+ import mcp.types as types
31
+ from mcp.server.lowlevel import Server
32
+ from mcp.server.models import InitializationOptions
33
+ from mcp.server.stdio import stdio_server
34
+
35
+ from . import __version__
36
+ from .adapters import AdapterError, discovery_errors, find_tool, iter_tools
37
+ from .guard import (
38
+ ParamsRejected,
39
+ PHIRejected,
40
+ safe_tool_name,
41
+ safe_validation_message,
42
+ )
43
+ from .http import SourceUnreachable
44
+
45
+ __all__ = ["build_server", "call_tool_payload", "list_tool_names", "serve_stdio", "list_mcp_tools"]
46
+
47
+ SERVER_NAME = "sourcelock"
48
+ INSTRUCTIONS = (
49
+ "SourceLock serves public healthcare data sources with an evidence receipt on every "
50
+ "answer. Parameters are public and typed; input shaped like a patient record is refused "
51
+ "on a structural best-effort basis -- that is a guard, not a compliance control, so do "
52
+ "not send patient data to these tools on the strength of it. Each "
53
+ "result carries a receipt naming the source release, the SHA-256 of the raw bytes behind "
54
+ "the answer, and explicit non-claims stating what the answer does not prove. Read the "
55
+ "non_claims before acting on a result."
56
+ )
57
+
58
+
59
+ def call_tool_payload(name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
60
+ """Invoke a tool by name and return a JSON-serialisable payload.
61
+
62
+ Raises ``KeyError`` for an unknown tool and
63
+ :class:`hc_source.guard.PHIRejected` for patient-shaped arguments.
64
+ """
65
+ tool = find_tool(name)
66
+ result = tool.invoke(arguments or {})
67
+ return {"data": result.data, "receipt": result.receipt.model_dump(mode="json")}
68
+
69
+
70
+ def list_tool_names() -> list[str]:
71
+ return [t.name for t in iter_tools()]
72
+
73
+
74
+ def list_mcp_tools() -> list[types.Tool]:
75
+ """Advertise each adapter's own parameter schema, unknown-fields-forbidden."""
76
+ return [
77
+ types.Tool(
78
+ name=spec.name,
79
+ description=spec.description,
80
+ inputSchema=spec.params_schema(),
81
+ )
82
+ for spec in iter_tools()
83
+ ]
84
+
85
+
86
+ def _text_result(payload: str, *, is_error: bool = False) -> types.CallToolResult:
87
+ return types.CallToolResult(
88
+ content=[types.TextContent(type="text", text=payload)], isError=is_error
89
+ )
90
+
91
+
92
+ async def _on_list_tools(ctx: Any, params: Any) -> types.ListToolsResult:
93
+ return types.ListToolsResult(tools=list_mcp_tools())
94
+
95
+
96
+ async def _on_call_tool(ctx: Any, params: types.CallToolRequestParams) -> types.CallToolResult:
97
+ """Run a tool. Arguments arrive exactly as the client sent them."""
98
+ arguments = dict(params.arguments or {})
99
+
100
+ try:
101
+ payload = await anyio.to_thread.run_sync(
102
+ lambda: call_tool_payload(params.name, arguments)
103
+ )
104
+ except KeyError:
105
+ # The name is client-supplied text. Reflecting it back verbatim let a
106
+ # client write an arbitrary string into the server's error output --
107
+ # including one it had just been refused for sending as a parameter.
108
+ return _text_result(
109
+ f"No tool named {safe_tool_name(params.name)}. Call tools/list to see what "
110
+ "this server offers.",
111
+ is_error=True,
112
+ )
113
+ except SourceUnreachable as exc:
114
+ # Not the client's fault and not retryable by fixing the arguments.
115
+ # Carries a URL and a transport reason only -- no body, no parameters.
116
+ return _text_result(
117
+ f"{safe_tool_name(params.name)}: source unreachable -- {exc.url} ({exc.reason}"
118
+ + (f", HTTP {exc.status}" if exc.status else "")
119
+ + "). No answer was returned because there would be no evidence behind it.",
120
+ is_error=True,
121
+ )
122
+ except (PHIRejected, ParamsRejected) as exc:
123
+ # Both carry field names and rule text, never values, by construction.
124
+ return _text_result(str(exc), is_error=True)
125
+ except ValueError as exc:
126
+ # Defence in depth: ToolSpec.invoke converts pydantic's ValidationError
127
+ # into ParamsRejected, so a bare ValueError here came from adapter code.
128
+ # Report the field and the rule, never the value: a rejected value may
129
+ # be exactly what must not be echoed.
130
+ return _text_result(
131
+ f"{safe_tool_name(params.name)}: invalid parameters. {_safe_reason(exc)}",
132
+ is_error=True,
133
+ )
134
+
135
+ text = json.dumps(payload, indent=2)
136
+ return types.CallToolResult(
137
+ content=[types.TextContent(type="text", text=text)],
138
+ structuredContent=payload,
139
+ isError=False,
140
+ )
141
+
142
+
143
+ def _safe_reason(exc: ValueError) -> str:
144
+ """Summarise a validation failure without repeating any input value.
145
+
146
+ Round-1 finding C1: the previous version read ``error["msg"]`` directly and
147
+ correctly avoided ``error["input"]`` -- but pydantic copies a custom
148
+ validator's text into ``msg`` verbatim, so a validator that quoted its input
149
+ put the value straight back into the client's error. ``safe_validation_message``
150
+ scrubs ``msg`` against ``input`` before it is used.
151
+ """
152
+ errors = getattr(exc, "errors", None)
153
+ if not callable(errors):
154
+ return type(exc).__name__
155
+ return safe_validation_message(exc)
156
+
157
+
158
+ def build_server() -> Server:
159
+ """A low-level MCP Server with SourceLock's tool handlers wired in."""
160
+ return Server(
161
+ name=SERVER_NAME,
162
+ version=__version__,
163
+ instructions=INSTRUCTIONS,
164
+ on_list_tools=_on_list_tools,
165
+ on_call_tool=_on_call_tool,
166
+ )
167
+
168
+
169
+ def serve_stdio() -> None:
170
+ """Run the server over stdio. Blocks until the client disconnects.
171
+
172
+ Refuses to start if any adapter module failed to import. Everywhere else a
173
+ load failure can be printed and read by a human; here the consumer is an
174
+ agent that sees only ``tools/list``, where a broken adapter and an
175
+ uninstalled one are indistinguishable. An agent that cannot see the gap will
176
+ answer around it, which is the worst outcome this product has: a confident
177
+ answer with a source silently missing from it.
178
+ """
179
+ errors = discovery_errors()
180
+ if errors:
181
+ raise AdapterError(
182
+ "refusing to serve MCP: "
183
+ + "; ".join(str(e) for e in errors)
184
+ + ". An MCP client cannot see a load failure -- it sees a shorter tools/list "
185
+ "and assumes that is the whole product. Fix the adapter, or remove its module "
186
+ "deliberately, before serving."
187
+ )
188
+ server = build_server()
189
+
190
+ async def _run() -> None:
191
+ async with stdio_server() as (read_stream, write_stream):
192
+ await server.run(
193
+ read_stream,
194
+ write_stream,
195
+ InitializationOptions(
196
+ server_name=SERVER_NAME,
197
+ server_version=__version__,
198
+ capabilities=server.get_capabilities(),
199
+ instructions=INSTRUCTIONS,
200
+ ),
201
+ )
202
+
203
+ anyio.run(_run)
hc_source/npi.py ADDED
@@ -0,0 +1,50 @@
1
+ """The one NPI validator.
2
+
3
+ The National Provider Identifier is a 10-digit number whose last digit is a Luhn
4
+ check digit computed over the number prefixed with the NPI issuer prefix
5
+ ``80840``. Because that prefix is constant, its Luhn contribution is the constant
6
+ ``24``, which is why the loop below starts there.
7
+
8
+ This lives in ``hc_source/`` rather than in an adapter because two adapters need
9
+ it and a second copy is a second chance to be wrong: ``provider`` rejected
10
+ mistyped NPIs at the parameter boundary while ``leie`` accepted any ten digits,
11
+ so a transposed NPI produced a confident "not excluded" screen against a number
12
+ that cannot exist.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __all__ = ["NPI_PATTERN", "npi_check_digit_ok", "validate_npi"]
18
+
19
+ #: Shape only. Shape is not validity -- always follow it with the check digit.
20
+ NPI_PATTERN = r"^\d{10}$"
21
+
22
+ #: Luhn contribution of the constant ``80840`` issuer prefix.
23
+ _PREFIX_CONTRIBUTION = 24
24
+
25
+
26
+ def npi_check_digit_ok(npi: str) -> bool:
27
+ """True when ``npi`` is ten digits whose check digit passes the 80840 Luhn."""
28
+ if len(npi) != 10 or not npi.isdigit():
29
+ return False
30
+ digits = [int(c) for c in npi]
31
+ total = _PREFIX_CONTRIBUTION
32
+ for i, d in enumerate(digits[:9]):
33
+ if i % 2 == 0: # doubling starts from the rightmost base digit (index 8)
34
+ d *= 2
35
+ if d > 9:
36
+ d -= 9
37
+ total += d
38
+ return (10 - total % 10) % 10 == digits[9]
39
+
40
+
41
+ def validate_npi(npi: str) -> str:
42
+ """Return ``npi`` unchanged, or raise ``ValueError`` naming the rule it broke.
43
+
44
+ Suitable as the body of a pydantic ``field_validator``. The message never
45
+ repeats the value: a rejected NPI is caller-supplied input, and echoing it
46
+ back is how a value we refused ends up in a log.
47
+ """
48
+ if not npi_check_digit_ok(npi):
49
+ raise ValueError("not a valid NPI: check digit fails the 80840 Luhn test")
50
+ return npi