From 211194bbaa4d956f0b4983f735bed43e56cc2a87 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 11:38:56 +0700 Subject: [PATCH 01/10] feat: add deterministic MCP lockfiles --- pyproject.toml | 28 ++++++++++ src/mcplock/__init__.py | 0 src/mcplock/contract.py | 114 ++++++++++++++++++++++++++++++++++++++++ tests/test_contract.py | 101 +++++++++++++++++++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/mcplock/__init__.py create mode 100644 src/mcplock/contract.py create mode 100644 tests/test_contract.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0ac242e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "mcplock" +version = "0.1.0" +description = "Catch breaking MCP tool changes before your agents do" +requires-python = ">=3.11" +license = "MIT" +keywords = ["ai-agents", "mcp", "model-context-protocol", "testing"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [] + +[project.scripts] +mcplock = "mcplock.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcplock"] diff --git a/src/mcplock/__init__.py b/src/mcplock/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcplock/contract.py b/src/mcplock/contract.py new file mode 100644 index 0000000..6d01409 --- /dev/null +++ b/src/mcplock/contract.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +import math +from typing import Any + +LOCK_VERSION = 1 +Json = dict[str, Any] | list[Any] | str | int | float | bool | None +_SCALAR = (str, int, float, bool, type(None)) + + +class ContractError(ValueError): + pass + + +def _json_key(value: Json) -> str: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _check_finite(value: Json) -> None: + if isinstance(value, float) and not math.isfinite(value): + raise ContractError("JSON numbers must be finite") + if isinstance(value, dict): + for child in value.values(): + _check_finite(child) + elif isinstance(value, list): + for child in value: + _check_finite(child) + + +def canonicalize(value: Json, parent_key: str | None = None) -> Json: + _check_finite(value) + if isinstance(value, dict): + return {key: canonicalize(value[key], key) for key in sorted(value)} + if isinstance(value, list): + items = [canonicalize(item) for item in value] + if parent_key in {"required", "type"} and all( + isinstance(item, str) for item in items + ): + return sorted(items) + if parent_key == "enum" and all(isinstance(item, _SCALAR) for item in items): + return sorted(items, key=_json_key) + return items + return value + + +def build_lock( + protocol_version: str, + server: dict[str, Any], + tools: list[dict[str, Any]], +) -> dict[str, Any]: + if not isinstance(protocol_version, str) or not protocol_version: + raise ContractError("protocol version must be a non-empty string") + if not isinstance(server, dict): + raise ContractError("server info must be an object") + name, version = server.get("name"), server.get("version") + if not isinstance(name, str) or not name: + raise ContractError("server name must be a non-empty string") + if not isinstance(version, str) or not version: + raise ContractError("server version must be a non-empty string") + + seen: set[str] = set() + normalized: list[dict[str, Any]] = [] + for tool in tools: + if not isinstance(tool, dict): + raise ContractError("each tool must be an object") + tool_name = tool.get("name") + if not isinstance(tool_name, str) or not tool_name: + raise ContractError("tool name must be a non-empty string") + if tool_name in seen: + raise ContractError(f"duplicate tool name: {tool_name}") + if not isinstance(tool.get("inputSchema"), dict): + raise ContractError(f"tool {tool_name!r} requires an inputSchema object") + if "outputSchema" in tool and not isinstance(tool["outputSchema"], dict): + raise ContractError(f"tool {tool_name!r} outputSchema must be an object") + seen.add(tool_name) + item = canonicalize(tool) + assert isinstance(item, dict) + normalized.append(item) + + normalized.sort(key=lambda item: item["name"]) + compact = json.dumps( + normalized, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return { + "lockVersion": LOCK_VERSION, + "protocolVersion": protocol_version, + "server": {"name": name, "version": version}, + "stats": {"definitionBytes": len(compact), "toolCount": len(normalized)}, + "tools": normalized, + } + + +def serialize_lock(lock: dict[str, Any]) -> bytes: + return ( + json.dumps( + canonicalize(lock), + ensure_ascii=False, + sort_keys=True, + indent=2, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..762a80a --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,101 @@ +import json +import math +import unittest + +from mcplock.contract import ContractError, build_lock, canonicalize, serialize_lock + + +class ContractArtifactTests(unittest.TestCase): + def test_canonicalize_sorts_only_semantic_sets(self): + value = { + "z": 1, + "required": ["beta", "alpha"], + "type": ["string", "null"], + "enum": ["z", "a", None], + "oneOf": [{"type": "string"}, {"type": "integer"}], + } + result = canonicalize(value) + self.assertEqual(list(result), ["enum", "oneOf", "required", "type", "z"]) + self.assertEqual(result["required"], ["alpha", "beta"]) + self.assertEqual(result["type"], ["null", "string"]) + self.assertEqual(result["enum"], ["a", "z", None]) + self.assertEqual( + result["oneOf"], + [{"type": "string"}, {"type": "integer"}], + ) + + def test_build_lock_sorts_tools_and_computes_stats(self): + tools = [ + {"name": "zeta", "inputSchema": {"type": "object"}}, + { + "name": "alpha", + "inputSchema": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + }, + ] + lock = build_lock( + "2025-11-25", + {"name": "fixture", "version": "1.0.0", "title": "ignored"}, + tools, + ) + self.assertEqual(lock["lockVersion"], 1) + self.assertEqual(set(lock), { + "lockVersion", "protocolVersion", "server", "stats", "tools", + }) + self.assertEqual(lock["server"], {"name": "fixture", "version": "1.0.0"}) + self.assertEqual([item["name"] for item in lock["tools"]], ["alpha", "zeta"]) + compact = json.dumps( + lock["tools"], + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + self.assertEqual(lock["stats"], { + "definitionBytes": len(compact), + "toolCount": 2, + }) + + def test_serialization_is_byte_identical_with_one_final_newline(self): + lock = build_lock( + "2025-11-25", + {"name": "fixture", "version": "1.0.0"}, + [{"name": "ping", "inputSchema": {"type": "object"}}], + ) + self.assertEqual(serialize_lock(lock), serialize_lock(lock)) + self.assertTrue(serialize_lock(lock).endswith(b"\n")) + self.assertFalse(serialize_lock(lock).endswith(b"\n\n")) + + def test_invalid_tools_duplicate_names_and_nan_are_rejected(self): + invalid = [ + {}, + {"name": "", "inputSchema": {}}, + {"name": "x"}, + {"name": "x", "inputSchema": None}, + ] + for tool in invalid: + with self.subTest(tool=tool), self.assertRaises(ContractError): + build_lock( + "2025-11-25", + {"name": "fixture", "version": "1.0.0"}, + [tool], + ) + duplicate = {"name": "same", "inputSchema": {"type": "object"}} + with self.assertRaisesRegex(ContractError, "duplicate tool name"): + build_lock( + "2025-11-25", + {"name": "fixture", "version": "1.0.0"}, + [duplicate, duplicate], + ) + with self.assertRaisesRegex(ContractError, "finite"): + build_lock( + "2025-11-25", + {"name": "fixture", "version": "1.0.0"}, + [{"name": "bad", "inputSchema": {"maximum": math.inf}}], + ) + + +if __name__ == "__main__": + unittest.main() From 954c223841cb992b04ce72759acd26fa5d8c2eaf Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 11:47:43 +0700 Subject: [PATCH 02/10] feat: classify MCP contract changes --- src/mcplock/contract.py | 224 ++++++++++++++++++++++++++++++++++++++++ tests/test_contract.py | 205 ++++++++++++++++++++++++++++++++++++ 2 files changed, 429 insertions(+) diff --git a/src/mcplock/contract.py b/src/mcplock/contract.py index 6d01409..0b1d413 100644 --- a/src/mcplock/contract.py +++ b/src/mcplock/contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass import json import math from typing import Any @@ -7,12 +8,27 @@ LOCK_VERSION = 1 Json = dict[str, Any] | list[Any] | str | int | float | bool | None _SCALAR = (str, int, float, bool, type(None)) +_ALL_TYPES = frozenset({ + "array", "boolean", "integer", "null", "number", "object", "string", +}) +_KNOWN_SCHEMA_KEYS = { + "type", "enum", "properties", "required", "additionalProperties", +} +_ORDER = {"breaking": 0, "warning": 1, "info": 2} +_MISSING = object() class ContractError(ValueError): pass +@dataclass(frozen=True, slots=True) +class Change: + severity: str + path: str + message: str + + def _json_key(value: Json) -> str: return json.dumps( value, @@ -112,3 +128,211 @@ def serialize_lock(lock: dict[str, Any]) -> bytes: ) + "\n" ).encode("utf-8") + + +def _types(schema): + value = schema.get("type", _MISSING) + if value is _MISSING: + return _ALL_TYPES + if isinstance(value, str): + return frozenset({value}) + if isinstance(value, list) and all(isinstance(item, str) for item in value): + return frozenset(value) + return None + + +def _strings(value): + return set(value) if isinstance(value, list) and all( + isinstance(item, str) for item in value + ) else None + + +def _enums(value): + return {_json_key(item): item for item in value} if isinstance(value, list) else None + + +def _raw(mapping, key): + return mapping[key] if key in mapping else _MISSING + + +def _emit(changes, severity, path, message): + changes.append(Change(severity, path, message)) + + +def _compare_schema(baseline, current, path, changes): + if baseline == current: + return + + old_types, new_types = _types(baseline), _types(current) + if old_types is not None and new_types is not None and old_types != new_types: + if not old_types.issubset(new_types): + _emit( + changes, + "breaking", + f"{path}.type", + "accepted JSON types narrowed", + ) + elif old_types < new_types: + _emit( + changes, + "info", + f"{path}.type", + "accepted JSON types broadened", + ) + elif _raw(baseline, "type") != _raw(current, "type"): + _emit(changes, "warning", f"{path}.type", "type expression changed") + + old_required = _strings(baseline.get("required", [])) + new_required = _strings(current.get("required", [])) + if old_required is not None and new_required is not None: + for name in sorted(new_required - old_required): + _emit( + changes, + "breaking", + f"{path}.required.{name}", + "input became required", + ) + for name in sorted(old_required - new_required): + _emit( + changes, + "info", + f"{path}.required.{name}", + "input became optional", + ) + elif _raw(baseline, "required") != _raw(current, "required"): + _emit(changes, "warning", f"{path}.required", "required expression changed") + new_required = set() + + old_properties = baseline.get("properties", {}) + new_properties = current.get("properties", {}) + if isinstance(old_properties, dict) and isinstance(new_properties, dict): + for name in sorted(old_properties.keys() - new_properties.keys()): + _emit( + changes, + "breaking", + f"{path}.properties.{name}", + "input property removed", + ) + for name in sorted(new_properties.keys() - old_properties.keys()): + if new_required is None or name not in new_required: + _emit( + changes, + "info", + f"{path}.properties.{name}", + "optional input property added", + ) + for name in sorted(old_properties.keys() & new_properties.keys()): + old_child, new_child = old_properties[name], new_properties[name] + child_path = f"{path}.properties.{name}" + if isinstance(old_child, dict) and isinstance(new_child, dict): + _compare_schema(old_child, new_child, child_path, changes) + elif old_child != new_child: + _emit(changes, "warning", child_path, "property schema changed") + elif old_properties != new_properties: + _emit(changes, "warning", f"{path}.properties", "properties expression changed") + + old_enum_raw = _raw(baseline, "enum") + new_enum_raw = _raw(current, "enum") + old_enum, new_enum = _enums(old_enum_raw), _enums(new_enum_raw) + if old_enum is not None and new_enum is not None: + for key in sorted(old_enum.keys() - new_enum.keys()): + _emit( + changes, + "breaking", + f"{path}.enum", + f"enum value removed: {_json_key(old_enum[key])}", + ) + for key in sorted(new_enum.keys() - old_enum.keys()): + _emit( + changes, + "info", + f"{path}.enum", + f"enum value added: {_json_key(new_enum[key])}", + ) + elif old_enum_raw != new_enum_raw: + _emit(changes, "warning", f"{path}.enum", "enum expression changed") + + old_extra = baseline.get("additionalProperties", True) + new_extra = current.get("additionalProperties", True) + if old_extra is not False and new_extra is False: + _emit( + changes, + "breaking", + f"{path}.additionalProperties", + "additional properties forbidden", + ) + elif old_extra is False and new_extra is not False: + _emit( + changes, + "info", + f"{path}.additionalProperties", + "additional properties allowed", + ) + elif old_extra != new_extra: + _emit( + changes, + "warning", + f"{path}.additionalProperties", + "additionalProperties schema changed", + ) + + for key in sorted((baseline.keys() | current.keys()) - _KNOWN_SCHEMA_KEYS): + if _raw(baseline, key) != _raw(current, key): + _emit(changes, "warning", f"{path}.{key}", "schema keyword changed") + + +def compare_locks(baseline, current): + changes = [] + if baseline["protocolVersion"] != current["protocolVersion"]: + _emit(changes, "warning", "protocolVersion", "protocol version changed") + for key in ("name", "version"): + if baseline["server"][key] != current["server"][key]: + _emit(changes, "warning", f"server.{key}", "server identity changed") + + old_tools = {item["name"]: item for item in baseline["tools"]} + new_tools = {item["name"]: item for item in current["tools"]} + for name in sorted(old_tools.keys() - new_tools.keys()): + _emit(changes, "breaking", f"tools.{name}", "tool removed") + for name in sorted(new_tools.keys() - old_tools.keys()): + _emit(changes, "info", f"tools.{name}", "tool added") + for name in sorted(old_tools.keys() & new_tools.keys()): + old_tool, new_tool = old_tools[name], new_tools[name] + _compare_schema( + old_tool["inputSchema"], + new_tool["inputSchema"], + f"tools.{name}.inputSchema", + changes, + ) + metadata = (old_tool.keys() | new_tool.keys()) - {"name", "inputSchema"} + for key in sorted(metadata): + if _raw(old_tool, key) != _raw(new_tool, key): + _emit( + changes, + "warning", + f"tools.{name}.{key}", + "tool metadata changed", + ) + + old_bytes = baseline["stats"]["definitionBytes"] + new_bytes = current["stats"]["definitionBytes"] + growth = new_bytes - old_bytes + if growth > 0 and (growth >= 1024 or (old_bytes > 0 and growth / old_bytes >= 0.10)): + percent = round(growth / old_bytes * 100) if old_bytes else 0 + _emit( + changes, + "warning", + "stats.definitionBytes", + f"context grew {old_bytes} -> {new_bytes} bytes (+{percent}%)", + ) + elif growth < 0: + _emit( + changes, + "info", + "stats.definitionBytes", + f"context shrank {old_bytes} -> {new_bytes} bytes", + ) + + return sorted( + changes, + key=lambda item: (_ORDER[item.severity], item.path, item.message), + ) diff --git a/tests/test_contract.py b/tests/test_contract.py index 762a80a..fe746eb 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -1,8 +1,10 @@ +from copy import deepcopy import json import math import unittest from mcplock.contract import ContractError, build_lock, canonicalize, serialize_lock +from mcplock.contract import Change, compare_locks class ContractArtifactTests(unittest.TestCase): @@ -97,5 +99,208 @@ def test_invalid_tools_duplicate_names_and_nan_are_rejected(self): ) +def make_tool(): + return { + "name": "read_file", + "description": "Read one file", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "enum": ["a", "b"]}, + "encoding": {"type": ["string", "null"]}, + }, + "required": ["path"], + }, + } + + +def make_lock(tool=None, server=None, protocol="2025-11-25"): + return build_lock( + protocol, + server or {"name": "fixture", "version": "1.0.0"}, + [] if tool is False else [tool or make_tool()], + ) + + +class CompatibilityTests(unittest.TestCase): + def assert_has(self, changes, severity, path, message): + self.assertIn( + (severity, path, message), + [(c.severity, c.path, c.message) for c in changes], + ) + + def test_documented_breaking_changes(self): + baseline = make_lock() + cases = [] + + cases.append(( + make_lock(False), + "tools.read_file", + "tool removed", + )) + + required = make_tool() + required["inputSchema"]["required"].append("encoding") + cases.append(( + make_lock(required), + "tools.read_file.inputSchema.required.encoding", + "input became required", + )) + + removed = make_tool() + del removed["inputSchema"]["properties"]["encoding"] + cases.append(( + make_lock(removed), + "tools.read_file.inputSchema.properties.encoding", + "input property removed", + )) + + narrowed = make_tool() + narrowed["inputSchema"]["properties"]["encoding"]["type"] = "string" + cases.append(( + make_lock(narrowed), + "tools.read_file.inputSchema.properties.encoding.type", + "accepted JSON types narrowed", + )) + + enum = make_tool() + enum["inputSchema"]["properties"]["path"]["enum"] = ["a"] + cases.append(( + make_lock(enum), + "tools.read_file.inputSchema.properties.path.enum", + 'enum value removed: "b"', + )) + + closed = make_tool() + closed["inputSchema"]["additionalProperties"] = False + cases.append(( + make_lock(closed), + "tools.read_file.inputSchema.additionalProperties", + "additional properties forbidden", + )) + + for current, path, message in cases: + with self.subTest(path=path): + self.assert_has( + compare_locks(baseline, current), + "breaking", + path, + message, + ) + + def test_reverse_changes_are_informational(self): + added_required = make_tool() + added_required["inputSchema"]["required"].append("encoding") + removed_property = make_tool() + del removed_property["inputSchema"]["properties"]["encoding"] + narrowed = make_tool() + narrowed["inputSchema"]["properties"]["encoding"]["type"] = "string" + smaller_enum = make_tool() + smaller_enum["inputSchema"]["properties"]["path"]["enum"] = ["a"] + closed = make_tool() + closed["inputSchema"]["additionalProperties"] = False + cases = [ + (make_lock(False), make_lock(), "tools.read_file", "tool added"), + ( + make_lock(added_required), + make_lock(), + "tools.read_file.inputSchema.required.encoding", + "input became optional", + ), + ( + make_lock(removed_property), + make_lock(), + "tools.read_file.inputSchema.properties.encoding", + "optional input property added", + ), + ( + make_lock(narrowed), + make_lock(), + "tools.read_file.inputSchema.properties.encoding.type", + "accepted JSON types broadened", + ), + ( + make_lock(smaller_enum), + make_lock(), + "tools.read_file.inputSchema.properties.path.enum", + 'enum value added: "b"', + ), + ( + make_lock(closed), + make_lock(), + "tools.read_file.inputSchema.additionalProperties", + "additional properties allowed", + ), + ] + for baseline, current, path, message in cases: + with self.subTest(path=path): + self.assert_has( + compare_locks(baseline, current), + "info", + path, + message, + ) + + def test_uncertain_metadata_schema_output_and_server_changes_warn(self): + changed = make_tool() + changed["description"] = "Changed guidance" + changed["outputSchema"] = {"type": "object"} + changed["inputSchema"]["properties"]["path"]["pattern"] = "^[a-z]+$" + current = make_lock( + changed, + {"name": "renamed", "version": "2.0.0"}, + "2025-06-18", + ) + paths = { + item.path + for item in compare_locks(make_lock(), current) + if item.severity == "warning" + } + self.assertTrue({ + "server.name", + "server.version", + "protocolVersion", + "tools.read_file.description", + "tools.read_file.outputSchema", + "tools.read_file.inputSchema.properties.path.pattern", + }.issubset(paths)) + + def test_context_growth_warns_and_order_is_stable(self): + changed = make_tool() + changed["description"] += "x" * 1200 + changes = compare_locks(make_lock(), make_lock(changed)) + self.assertTrue(any( + item.severity == "warning" and item.path == "stats.definitionBytes" + for item in changes + )) + order = {"breaking": 0, "warning": 1, "info": 2} + self.assertEqual( + changes, + sorted(changes, key=lambda item: ( + order[item.severity], item.path, item.message + )), + ) + shrink = compare_locks(make_lock(changed), make_lock()) + self.assertTrue(any( + item.severity == "info" and item.path == "stats.definitionBytes" + for item in shrink + )) + + def test_missing_and_explicit_null_keyword_are_not_silently_equal(self): + changed = make_tool() + changed["inputSchema"]["properties"]["path"]["format"] = None + changes = compare_locks(make_lock(), make_lock(changed)) + self.assert_has( + changes, + "warning", + "tools.read_file.inputSchema.properties.path.format", + "schema keyword changed", + ) + + def test_unchanged_contract_has_no_changes(self): + lock = make_lock() + self.assertEqual(compare_locks(lock, deepcopy(lock)), []) + + if __name__ == "__main__": unittest.main() From 13c32b050c23a88b97a1cfc522440e76528c1890 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 11:58:33 +0700 Subject: [PATCH 03/10] feat: discover stdio MCP tools --- src/mcplock/stdio.py | 230 +++++++++++++++++++++++++++++++++++++++++++ tests/fake_server.py | 82 +++++++++++++++ tests/test_stdio.py | 41 ++++++++ 3 files changed, 353 insertions(+) create mode 100644 src/mcplock/stdio.py create mode 100644 tests/fake_server.py create mode 100644 tests/test_stdio.py diff --git a/src/mcplock/stdio.py b/src/mcplock/stdio.py new file mode 100644 index 0000000..2a233dd --- /dev/null +++ b/src/mcplock/stdio.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from dataclasses import dataclass +import json +from typing import Any, Sequence + +SUPPORTED_PROTOCOL_VERSIONS = frozenset({ + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05", +}) +LATEST_PROTOCOL_VERSION = "2025-11-25" +MAX_MESSAGE_BYTES = 16 * 1024 * 1024 +STDERR_LIMIT = 32 * 1024 + + +class DiscoveryError(RuntimeError): + def __init__(self, message, diagnostic=""): + super().__init__(message) + self.diagnostic = diagnostic + + +@dataclass(frozen=True, slots=True) +class Discovery: + protocol_version: str + server: dict[str, Any] + tools: list[dict[str, Any]] + + +class _Client: + def __init__(self, process, timeout): + self.process = process + self.timeout = timeout + self.next_id = 1 + + async def _send(self, message): + data = json.dumps( + message, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + b"\n" + self.process.stdin.write(data) + await self.process.stdin.drain() + + async def _read(self): + try: + line = await asyncio.wait_for( + self.process.stdout.readline(), + timeout=self.timeout, + ) + except asyncio.TimeoutError as exc: + raise DiscoveryError("MCP request timed out") from exc + except ValueError as exc: + raise DiscoveryError("MCP message exceeds 16 MiB") from exc + if not line: + raise DiscoveryError("MCP server exited before responding") + if len(line) > MAX_MESSAGE_BYTES: + raise DiscoveryError("MCP message exceeds 16 MiB") + try: + message = json.loads(line.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + preview = line[:160].decode("utf-8", errors="replace").rstrip() + raise DiscoveryError(f"invalid MCP stdout: {preview}") from exc + if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": + raise DiscoveryError("MCP message must be a JSON-RPC 2.0 object") + return message + + async def notify(self, method): + await self._send({"jsonrpc": "2.0", "method": method}) + + async def request(self, method, params=None): + request_id = self.next_id + self.next_id += 1 + request = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + request["params"] = params + await self._send(request) + while True: + response = await self._read() + if "method" in response and "id" not in response: + continue + if "method" in response and "id" in response: + await self._send({ + "jsonrpc": "2.0", + "id": response["id"], + "error": {"code": -32601, "message": "Method not found"}, + }) + continue + if response.get("id") != request_id: + raise DiscoveryError("MCP response used an unexpected request id") + if "error" in response: + error = response["error"] + text = error.get("message", "unknown error") if isinstance( + error, dict + ) else str(error) + raise DiscoveryError(f"MCP {method} failed: {text}") + result = response.get("result") + if not isinstance(result, dict): + raise DiscoveryError(f"MCP {method} returned a non-object result") + return result + + +async def _drain_stderr(stream, tail): + while chunk := await stream.read(4096): + tail.extend(chunk) + if len(tail) > STDERR_LIMIT: + del tail[:-STDERR_LIMIT] + + +async def _shutdown(process): + if process.stdin is not None and not process.stdin.is_closing(): + process.stdin.close() + with suppress(BrokenPipeError, ConnectionResetError): + await process.stdin.wait_closed() + try: + await asyncio.wait_for(process.wait(), timeout=0.5) + return + except asyncio.TimeoutError: + with suppress(ProcessLookupError): + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=0.5) + except asyncio.TimeoutError: + with suppress(ProcessLookupError): + process.kill() + await process.wait() + + +async def discover(command: Sequence[str], timeout: float = 30.0) -> Discovery: + if not command: + raise DiscoveryError("server command is required") + if timeout <= 0: + raise DiscoveryError("timeout must be greater than zero") + try: + process = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + limit=MAX_MESSAGE_BYTES + 1, + ) + except OSError as exc: + raise DiscoveryError(f"could not launch MCP server: {exc}") from exc + + stderr_tail = bytearray() + stderr_task = asyncio.create_task( + _drain_stderr(process.stderr, stderr_tail) + ) + client = _Client(process, timeout) + failure = None + discovered = None + try: + initialized = await client.request("initialize", { + "protocolVersion": LATEST_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "mcplock", "version": "0.1.0"}, + }) + protocol_version = initialized.get("protocolVersion") + if protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: + raise DiscoveryError( + f"unsupported MCP protocol version: {protocol_version}" + ) + capabilities = initialized.get("capabilities") + if not isinstance(capabilities, dict) or "tools" not in capabilities: + raise DiscoveryError( + "MCP server did not advertise the tools capability" + ) + server = initialized.get("serverInfo") + if not isinstance(server, dict): + raise DiscoveryError("MCP initialize result omitted serverInfo") + await client.notify("notifications/initialized") + + tools = [] + cursor = None + seen_cursors = set() + while True: + params = {"cursor": cursor} if cursor is not None else {} + page = await client.request("tools/list", params) + page_tools = page.get("tools") + if not isinstance(page_tools, list) or not all( + isinstance(item, dict) for item in page_tools + ): + raise DiscoveryError( + "MCP tools/list returned an invalid tools array" + ) + tools.extend(page_tools) + next_cursor = page.get("nextCursor") + if next_cursor is None: + break + if not isinstance(next_cursor, str) or not next_cursor: + raise DiscoveryError( + "MCP tools/list returned an invalid nextCursor" + ) + if next_cursor in seen_cursors: + raise DiscoveryError( + f"MCP pagination cursor repeated: {next_cursor}" + ) + seen_cursors.add(next_cursor) + cursor = next_cursor + + names = [] + for item in tools: + name = item.get("name") + if not isinstance(name, str) or not name: + raise DiscoveryError( + "MCP tools/list returned a tool without a valid name" + ) + names.append(name) + if len(names) != len(set(names)): + raise DiscoveryError( + "MCP tools/list returned duplicate tool names" + ) + discovered = Discovery(protocol_version, server, tools) + except BaseException as exc: + failure = exc + finally: + await _shutdown(process) + await stderr_task + + diagnostic = stderr_tail.decode("utf-8", errors="replace").rstrip() + if failure is not None: + if isinstance(failure, DiscoveryError): + raise DiscoveryError(str(failure), diagnostic) from failure + raise failure + assert discovered is not None + return discovered diff --git a/tests/fake_server.py b/tests/fake_server.py new file mode 100644 index 0000000..87ec13e --- /dev/null +++ b/tests/fake_server.py @@ -0,0 +1,82 @@ +import argparse +import json +import sys + + +def receive(): + line = sys.stdin.buffer.readline() + if not line: + raise SystemExit(0) + return json.loads(line) + + +def send(message): + sys.stdout.buffer.write( + json.dumps(message, separators=(",", ":")).encode("utf-8") + b"\n" + ) + sys.stdout.buffer.flush() + + +def tool(name): + return { + "name": name, + "description": f"Fixture tool {name}", + "inputSchema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--scenario", + choices=["baseline", "breaking", "pagination", "legacy"], + default="baseline", + ) + args = parser.parse_args() + + initialize = receive() + version = "2024-11-05" if args.scenario == "legacy" else "2025-11-25" + send({ + "jsonrpc": "2.0", + "id": initialize["id"], + "result": { + "protocolVersion": version, + "capabilities": {"tools": {}}, + "serverInfo": {"name": "fixture", "version": "1.0.0"}, + }, + }) + initialized = receive() + assert initialized["method"] == "notifications/initialized" + + request = receive() + assert request["method"] == "tools/list" + if args.scenario == "pagination": + send({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"tools": [tool("alpha")], "nextCursor": "page-2"}, + }) + request = receive() + assert request["params"]["cursor"] == "page-2" + tools = [tool("zeta")] + elif args.scenario == "breaking": + changed = tool("read_file") + changed["inputSchema"]["properties"]["encoding"] = {"type": "string"} + changed["inputSchema"]["required"].append("encoding") + tools = [changed] + else: + tools = [tool("read_file")] + + send({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"tools": tools}, + }) + + +if __name__ == "__main__": + main() diff --git a/tests/test_stdio.py b/tests/test_stdio.py new file mode 100644 index 0000000..6106b35 --- /dev/null +++ b/tests/test_stdio.py @@ -0,0 +1,41 @@ +import asyncio +from pathlib import Path +import sys +import unittest + +from mcplock.stdio import Discovery, SUPPORTED_PROTOCOL_VERSIONS, discover + +FAKE_SERVER = Path(__file__).with_name("fake_server.py") + + +def command(scenario): + return [sys.executable, str(FAKE_SERVER), "--scenario", scenario] + + +class DiscoveryTests(unittest.TestCase): + def test_supported_versions_are_exact(self): + self.assertEqual(SUPPORTED_PROTOCOL_VERSIONS, { + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05", + }) + + def test_discovers_one_tool(self): + result = asyncio.run(discover(command("baseline"), timeout=2.0)) + self.assertIsInstance(result, Discovery) + self.assertEqual(result.protocol_version, "2025-11-25") + self.assertEqual(result.server, {"name": "fixture", "version": "1.0.0"}) + self.assertEqual([item["name"] for item in result.tools], ["read_file"]) + + def test_follows_every_page(self): + result = asyncio.run(discover(command("pagination"), timeout=2.0)) + self.assertEqual([item["name"] for item in result.tools], ["alpha", "zeta"]) + + def test_accepts_oldest_supported_revision(self): + result = asyncio.run(discover(command("legacy"), timeout=2.0)) + self.assertEqual(result.protocol_version, "2024-11-05") + + +if __name__ == "__main__": + unittest.main() From 784b391cc1290d8cb59d961f709e2716c02f289c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 12:13:31 +0700 Subject: [PATCH 04/10] fix: harden MCP discovery failures --- src/mcplock/stdio.py | 13 +++++- tests/fake_server.py | 98 ++++++++++++++++++++++++++++++++++++++++++-- tests/test_stdio.py | 93 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 197 insertions(+), 7 deletions(-) diff --git a/src/mcplock/stdio.py b/src/mcplock/stdio.py index 2a233dd..70bfce1 100644 --- a/src/mcplock/stdio.py +++ b/src/mcplock/stdio.py @@ -17,6 +17,10 @@ STDERR_LIMIT = 32 * 1024 +def _reject_json_constant(value): + raise ValueError(f"invalid JSON constant: {value}") + + class DiscoveryError(RuntimeError): def __init__(self, message, diagnostic=""): super().__init__(message) @@ -61,8 +65,13 @@ async def _read(self): if len(line) > MAX_MESSAGE_BYTES: raise DiscoveryError("MCP message exceeds 16 MiB") try: - message = json.loads(line.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: + if not line.endswith(b"\n"): + raise ValueError("MCP message is not newline-terminated") + message = json.loads( + line.decode("utf-8"), + parse_constant=_reject_json_constant, + ) + except (UnicodeDecodeError, ValueError) as exc: preview = line[:160].decode("utf-8", errors="replace").rstrip() raise DiscoveryError(f"invalid MCP stdout: {preview}") from exc if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": diff --git a/tests/fake_server.py b/tests/fake_server.py index 87ec13e..ffa51a2 100644 --- a/tests/fake_server.py +++ b/tests/fake_server.py @@ -1,6 +1,7 @@ import argparse import json import sys +import time def receive(): @@ -33,19 +34,81 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument( "--scenario", - choices=["baseline", "breaking", "pagination", "legacy"], + choices=[ + "baseline", + "breaking", + "pagination", + "legacy", + "notification", + "client-request", + "duplicate", + "cycle", + "invalid-tool", + "invalid-cursor", + "rpc-error", + "bad-jsonrpc", + "malformed", + "stderr-exit", + "stderr-large", + "hang", + "missing-tools", + "unsupported", + "oversized", + ], default="baseline", ) args = parser.parse_args() + if args.scenario == "malformed": + sys.stdout.write("not-json\n") + sys.stdout.flush() + time.sleep(1) + return + if args.scenario == "bad-jsonrpc": + send({"id": 1, "result": {}}) + time.sleep(1) + return + if args.scenario == "stderr-exit": + sys.stderr.write("fixture launch failed: secret-free diagnostic\n") + sys.stderr.flush() + return + if args.scenario == "stderr-large": + sys.stderr.buffer.write(b"x" * 40000 + b"TAIL\n") + sys.stderr.buffer.flush() + return + if args.scenario == "hang": + time.sleep(10) + return + if args.scenario == "oversized": + sys.stdout.buffer.write( + b'{"jsonrpc":"2.0","note":"' + b"x" * (16 * 1024 * 1024) + b'"}\n' + ) + sys.stdout.buffer.flush() + time.sleep(1) + return + initialize = receive() - version = "2024-11-05" if args.scenario == "legacy" else "2025-11-25" + if args.scenario == "notification": + send({"jsonrpc": "2.0", "method": "notifications/progress"}) + if args.scenario == "client-request": + send({"jsonrpc": "2.0", "id": 900, "method": "roots/list"}) + rejection = receive() + assert rejection["id"] == 900 + assert rejection["error"]["code"] == -32601 + + if args.scenario == "legacy": + version = "2024-11-05" + elif args.scenario == "unsupported": + version = "2099-01-01" + else: + version = "2025-11-25" + capabilities = {} if args.scenario == "missing-tools" else {"tools": {}} send({ "jsonrpc": "2.0", "id": initialize["id"], "result": { "protocolVersion": version, - "capabilities": {"tools": {}}, + "capabilities": capabilities, "serverInfo": {"name": "fixture", "version": "1.0.0"}, }, }) @@ -54,6 +117,26 @@ def main(): request = receive() assert request["method"] == "tools/list" + if args.scenario == "rpc-error": + send({ + "jsonrpc": "2.0", + "id": request["id"], + "error": {"code": -32603, "message": "fixture failure"}, + }) + return + if args.scenario == "cycle": + for _ in range(2): + send({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "tools": [tool("read_file")], + "nextCursor": "again", + }, + }) + request = receive() + assert request["params"]["cursor"] == "again" + return if args.scenario == "pagination": send({ "jsonrpc": "2.0", @@ -68,13 +151,20 @@ def main(): changed["inputSchema"]["properties"]["encoding"] = {"type": "string"} changed["inputSchema"]["required"].append("encoding") tools = [changed] + elif args.scenario == "duplicate": + tools = [tool("same"), tool("same")] + elif args.scenario == "invalid-tool": + tools = [{"inputSchema": {}}] else: tools = [tool("read_file")] + result = {"tools": tools} + if args.scenario == "invalid-cursor": + result["nextCursor"] = 7 send({ "jsonrpc": "2.0", "id": request["id"], - "result": {"tools": tools}, + "result": result, }) diff --git a/tests/test_stdio.py b/tests/test_stdio.py index 6106b35..79b17ce 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -3,7 +3,12 @@ import sys import unittest -from mcplock.stdio import Discovery, SUPPORTED_PROTOCOL_VERSIONS, discover +from mcplock.stdio import ( + Discovery, + DiscoveryError, + SUPPORTED_PROTOCOL_VERSIONS, + discover, +) FAKE_SERVER = Path(__file__).with_name("fake_server.py") @@ -12,7 +17,23 @@ def command(scenario): return [sys.executable, str(FAKE_SERVER), "--scenario", scenario] +def raw_command(payload): + script = ( + "import sys;" + f"sys.stdout.buffer.write({payload!r});" + "sys.stdout.buffer.flush();" + "sys.stdout.close()" + ) + return [sys.executable, "-c", script] + + class DiscoveryTests(unittest.TestCase): + def assert_discovery_error(self, scenario, text, timeout=2.0): + with self.assertRaises(DiscoveryError) as caught: + asyncio.run(discover(command(scenario), timeout=timeout)) + self.assertIn(text, str(caught.exception)) + return caught.exception + def test_supported_versions_are_exact(self): self.assertEqual(SUPPORTED_PROTOCOL_VERSIONS, { "2025-11-25", @@ -36,6 +57,76 @@ def test_accepts_oldest_supported_revision(self): result = asyncio.run(discover(command("legacy"), timeout=2.0)) self.assertEqual(result.protocol_version, "2024-11-05") + def test_notifications_and_rejected_client_requests_continue(self): + for scenario in ("notification", "client-request"): + with self.subTest(scenario=scenario): + result = asyncio.run(discover(command(scenario), timeout=2.0)) + self.assertEqual( + [item["name"] for item in result.tools], + ["read_file"], + ) + + def test_protocol_failures_are_actionable(self): + cases = { + "duplicate": "duplicate tool names", + "cycle": "cursor repeated", + "invalid-tool": "valid name", + "invalid-cursor": "invalid nextCursor", + "rpc-error": "tools/list failed: fixture failure", + "bad-jsonrpc": "JSON-RPC 2.0 object", + "malformed": "invalid MCP stdout", + "missing-tools": "tools capability", + "unsupported": "unsupported MCP protocol version", + "oversized": "exceeds 16 MiB", + } + for scenario, text in cases.items(): + with self.subTest(scenario=scenario): + self.assert_discovery_error(scenario, text) + for payload in ( + b'{"jsonrpc":"2.0","id":1,"result":{}}', + b'{"jsonrpc":"2.0","id":1,"result":NaN}\n', + b'{"jsonrpc":"2.0","id":1,"result":Infinity}\n', + ): + with self.subTest(payload=payload): + with self.assertRaisesRegex( + DiscoveryError, + "invalid MCP stdout", + ): + asyncio.run(discover(raw_command(payload), timeout=2.0)) + + def test_stderr_is_bounded_diagnostic_data(self): + error = self.assert_discovery_error( + "stderr-exit", + "exited before responding", + ) + self.assertEqual( + error.diagnostic, + "fixture launch failed: secret-free diagnostic", + ) + self.assertLessEqual(len(error.diagnostic.encode("utf-8")), 32 * 1024) + large = self.assert_discovery_error( + "stderr-large", + "exited before responding", + ) + self.assertLessEqual(len(large.diagnostic.encode("utf-8")), 32 * 1024) + self.assertTrue(large.diagnostic.endswith("TAIL")) + + def test_timeout_returns_after_child_cleanup(self): + loop = asyncio.new_event_loop() + started = loop.time() + try: + with self.assertRaisesRegex(DiscoveryError, "timed out"): + loop.run_until_complete(discover(command("hang"), timeout=0.1)) + self.assertLess(loop.time() - started, 2.0) + finally: + loop.close() + + def test_arguments_are_validated_before_launch(self): + with self.assertRaisesRegex(DiscoveryError, "command is required"): + asyncio.run(discover([], timeout=1)) + with self.assertRaisesRegex(DiscoveryError, "greater than zero"): + asyncio.run(discover(command("baseline"), timeout=0)) + if __name__ == "__main__": unittest.main() From 200b8438c910b5935a405cb3180ba160dee441dd Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 12:23:52 +0700 Subject: [PATCH 05/10] fix: bound MCP stderr diagnostics --- src/mcplock/stdio.py | 7 ++++++- tests/fake_server.py | 5 +++++ tests/test_stdio.py | 6 ++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/mcplock/stdio.py b/src/mcplock/stdio.py index 70bfce1..be58645 100644 --- a/src/mcplock/stdio.py +++ b/src/mcplock/stdio.py @@ -230,7 +230,12 @@ async def discover(command: Sequence[str], timeout: float = 30.0) -> Discovery: await _shutdown(process) await stderr_task - diagnostic = stderr_tail.decode("utf-8", errors="replace").rstrip() + diagnostic_bytes = stderr_tail.decode( + "utf-8", errors="replace" + ).encode("utf-8") + diagnostic = diagnostic_bytes[-STDERR_LIMIT:].decode( + "utf-8", errors="ignore" + ).rstrip() if failure is not None: if isinstance(failure, DiscoveryError): raise DiscoveryError(str(failure), diagnostic) from failure diff --git a/tests/fake_server.py b/tests/fake_server.py index ffa51a2..6d19a8c 100644 --- a/tests/fake_server.py +++ b/tests/fake_server.py @@ -50,6 +50,7 @@ def main(): "malformed", "stderr-exit", "stderr-large", + "stderr-invalid", "hang", "missing-tools", "unsupported", @@ -76,6 +77,10 @@ def main(): sys.stderr.buffer.write(b"x" * 40000 + b"TAIL\n") sys.stderr.buffer.flush() return + if args.scenario == "stderr-invalid": + sys.stderr.buffer.write(b"\xff" * 40000 + b"TAIL\n") + sys.stderr.buffer.flush() + return if args.scenario == "hang": time.sleep(10) return diff --git a/tests/test_stdio.py b/tests/test_stdio.py index 79b17ce..b1d8e3a 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -110,6 +110,12 @@ def test_stderr_is_bounded_diagnostic_data(self): ) self.assertLessEqual(len(large.diagnostic.encode("utf-8")), 32 * 1024) self.assertTrue(large.diagnostic.endswith("TAIL")) + invalid = self.assert_discovery_error( + "stderr-invalid", + "exited before responding", + ) + self.assertLessEqual(len(invalid.diagnostic.encode("utf-8")), 32 * 1024) + self.assertTrue(invalid.diagnostic.endswith("TAIL")) def test_timeout_returns_after_child_cleanup(self): loop = asyncio.new_event_loop() From b8e93db45e83477fb5380a037a8705f712f95dc4 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 12:37:08 +0700 Subject: [PATCH 06/10] feat: add MCPLock command workflow --- src/mcplock/__main__.py | 3 + src/mcplock/cli.py | 99 +++++++++++++++++++++++++++++++++ src/mcplock/contract.py | 56 +++++++++++++++++++ tests/test_cli.py | 120 ++++++++++++++++++++++++++++++++++++++++ tests/test_contract.py | 43 ++++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 src/mcplock/__main__.py create mode 100644 src/mcplock/cli.py create mode 100644 tests/test_cli.py diff --git a/src/mcplock/__main__.py b/src/mcplock/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/src/mcplock/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/mcplock/cli.py b/src/mcplock/cli.py new file mode 100644 index 0000000..dbfd91b --- /dev/null +++ b/src/mcplock/cli.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import argparse +import asyncio +from pathlib import Path +import sys + +from .contract import ( + ContractError, + build_lock, + compare_locks, + load_lock, + write_lock, +) +from .stdio import DiscoveryError, SUPPORTED_PROTOCOL_VERSIONS, discover + + +def positive_timeout(value): + timeout = float(value) + if timeout <= 0: + raise argparse.ArgumentTypeError("timeout must be greater than zero") + return timeout + + +def parser(): + result = argparse.ArgumentParser( + prog="mcplock", + description="Catch breaking MCP tool changes before your agents do.", + ) + commands = result.add_subparsers(dest="action", required=True) + for action in ("update", "check"): + command = commands.add_parser(action) + command.add_argument("--lock", type=Path, default=Path("mcp.lock.json")) + command.add_argument("--timeout", type=positive_timeout, default=30.0) + command.add_argument("server_command", nargs=argparse.REMAINDER) + return result + + +def render_changes(changes): + if not changes: + return "MCPLock: compatible\n" + counts = { + severity: sum(item.severity == severity for item in changes) + for severity in ("breaking", "warning", "info") + } + summary = ", ".join( + f"{count} {severity}" + for severity, count in counts.items() + if count + ) + lines = [f"MCPLock: {summary} change(s)", ""] + for item in changes: + lines.extend([ + f"{item.severity.upper():8} {item.path}", + f" {item.message}", + "", + ]) + return "\n".join(lines).rstrip() + "\n" + + +def main(argv=None): + args = parser().parse_args(argv) + command = list(args.server_command) + if command and command[0] == "--": + command.pop(0) + if not command: + print("MCPLock error: server command is required after --", file=sys.stderr) + return 2 + try: + discovery = discover(command, timeout=args.timeout) + try: + result = asyncio.run(discovery) + finally: + discovery.close() + current = build_lock(result.protocol_version, result.server, result.tools) + if args.action == "update": + write_lock(args.lock, current) + print( + f"MCPLock: wrote {args.lock} " + f"({current['stats']['toolCount']} tools)" + ) + return 0 + baseline = load_lock(args.lock) + if baseline["protocolVersion"] not in SUPPORTED_PROTOCOL_VERSIONS: + raise ContractError( + "unsupported lockfile protocol version: " + f"{baseline['protocolVersion']}" + ) + changes = compare_locks(baseline, current) + print(render_changes(changes), end="") + return 1 if any(item.severity == "breaking" for item in changes) else 0 + except KeyboardInterrupt: + return 130 + except (ContractError, DiscoveryError, OSError) as exc: + print(f"MCPLock error: {exc}", file=sys.stderr) + diagnostic = getattr(exc, "diagnostic", "") + if diagnostic: + print(diagnostic, file=sys.stderr) + return 2 diff --git a/src/mcplock/contract.py b/src/mcplock/contract.py index 0b1d413..c425dcf 100644 --- a/src/mcplock/contract.py +++ b/src/mcplock/contract.py @@ -3,6 +3,9 @@ from dataclasses import dataclass import json import math +import os +from pathlib import Path +import tempfile from typing import Any LOCK_VERSION = 1 @@ -130,6 +133,59 @@ def serialize_lock(lock: dict[str, Any]) -> bytes: ).encode("utf-8") +def _reject_constant(value): + raise ValueError(f"invalid JSON number: {value}") + + +def load_lock(path): + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ContractError( + f"lockfile not found: {path}; run mcplock update first" + ) from exc + except UnicodeDecodeError as exc: + raise ContractError(f"invalid lockfile JSON: {path}") from exc + try: + lock = json.loads(raw, parse_constant=_reject_constant) + except (json.JSONDecodeError, ValueError) as exc: + raise ContractError(f"invalid lockfile JSON: {path}") from exc + if not isinstance(lock, dict) or lock.get("lockVersion") != LOCK_VERSION: + raise ContractError(f"unsupported lockfile version in {path}") + required = {"protocolVersion", "server", "stats", "tools"} + if not required.issubset(lock): + raise ContractError(f"lockfile is missing required fields: {path}") + try: + rebuilt = build_lock(lock["protocolVersion"], lock["server"], lock["tools"]) + except (KeyError, TypeError, ContractError) as exc: + raise ContractError(f"invalid lockfile structure: {path}") from exc + if rebuilt != lock: + raise ContractError(f"lockfile is not canonical or has invalid stats: {path}") + return lock + + +def write_lock(path, lock): + if not path.parent.is_dir(): + raise OSError(f"lockfile directory does not exist: {path.parent}") + temporary = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + prefix=f".{path.name}.", + dir=path.parent, + delete=False, + ) as handle: + temporary = Path(handle.name) + handle.write(serialize_lock(lock)) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + def _types(schema): value = schema.get("type", _MISSING) if value is _MISSING: diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5388240 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,120 @@ +from contextlib import redirect_stderr, redirect_stdout +import gc +from io import StringIO +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import warnings + +from mcplock.cli import main +from mcplock.contract import build_lock, write_lock + +FAKE_SERVER = Path(__file__).with_name("fake_server.py") + + +def server(scenario): + return [sys.executable, str(FAKE_SERVER), "--scenario", scenario] + + +class CliTests(unittest.TestCase): + def invoke(self, args): + stdout, stderr = StringIO(), StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): + code = main(args) + return code, stdout.getvalue(), stderr.getvalue() + + def test_update_then_compatible_check(self): + with tempfile.TemporaryDirectory() as directory: + lock = str(Path(directory) / "mcp.lock.json") + code, output, error = self.invoke( + ["update", "--lock", lock, "--", *server("baseline")] + ) + self.assertEqual((code, error), (0, "")) + self.assertIn("wrote", output) + code, output, error = self.invoke( + ["check", "--lock", lock, "--", *server("baseline")] + ) + self.assertEqual((code, error), (0, "")) + self.assertIn("compatible", output) + + def test_breaking_check_exits_one_without_rewriting(self): + with tempfile.TemporaryDirectory() as directory: + lock = Path(directory) / "mcp.lock.json" + self.invoke(["update", "--lock", str(lock), "--", *server("baseline")]) + before = lock.read_bytes() + code, output, error = self.invoke( + ["check", "--lock", str(lock), "--", *server("breaking")] + ) + self.assertEqual((code, error), (1, "")) + self.assertIn("BREAKING", output) + self.assertIn("input became required", output) + self.assertEqual(lock.read_bytes(), before) + + def test_missing_lock_and_protocol_failure_exit_two(self): + with tempfile.TemporaryDirectory() as directory: + missing = str(Path(directory) / "missing.json") + code, _, error = self.invoke( + ["check", "--lock", missing, "--", *server("baseline")] + ) + self.assertEqual(code, 2) + self.assertIn("mcplock update", error) + code, _, error = self.invoke( + ["check", "--lock", missing, "--", *server("stderr-exit")] + ) + self.assertEqual(code, 2) + self.assertIn("fixture launch failed", error) + + def test_failed_update_preserves_existing_file(self): + with tempfile.TemporaryDirectory() as directory: + lock = Path(directory) / "mcp.lock.json" + lock.write_text("keep-me", encoding="utf-8") + code, _, _ = self.invoke( + ["update", "--lock", str(lock), "--", *server("malformed")] + ) + self.assertEqual(code, 2) + self.assertEqual(lock.read_text(encoding="utf-8"), "keep-me") + + def test_unsupported_lock_protocol_exits_two(self): + with tempfile.TemporaryDirectory() as directory: + lock = Path(directory) / "mcp.lock.json" + write_lock(lock, build_lock( + "2099-01-01", + {"name": "fixture", "version": "1.0.0"}, + [{ + "name": "read_file", + "inputSchema": {"type": "object"}, + }], + )) + code, _, error = self.invoke( + ["check", "--lock", str(lock), "--", *server("baseline")] + ) + self.assertEqual(code, 2) + self.assertIn("unsupported lockfile protocol version", error) + + def test_warning_only_check_exits_zero(self): + with tempfile.TemporaryDirectory() as directory: + lock = str(Path(directory) / "mcp.lock.json") + self.invoke(["update", "--lock", lock, "--", *server("baseline")]) + with patch("mcplock.cli.compare_locks") as compare: + from mcplock.contract import Change + compare.return_value = [Change("warning", "server.version", "changed")] + code, output, _ = self.invoke( + ["check", "--lock", lock, "--", *server("baseline")] + ) + self.assertEqual(code, 0) + self.assertIn("WARNING", output) + + def test_keyboard_interrupt_returns_130(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with patch("mcplock.cli.asyncio.run", side_effect=KeyboardInterrupt): + code, _, _ = self.invoke(["update", "--", "fixture"]) + gc.collect() + self.assertEqual(code, 130) + self.assertEqual(caught, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contract.py b/tests/test_contract.py index fe746eb..4a47458 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -1,10 +1,14 @@ from copy import deepcopy import json import math +from pathlib import Path +import tempfile import unittest +from unittest.mock import patch from mcplock.contract import ContractError, build_lock, canonicalize, serialize_lock from mcplock.contract import Change, compare_locks +from mcplock.contract import load_lock, write_lock class ContractArtifactTests(unittest.TestCase): @@ -302,5 +306,44 @@ def test_unchanged_contract_has_no_changes(self): self.assertEqual(compare_locks(lock, deepcopy(lock)), []) +class LockfileIoTests(unittest.TestCase): + def test_write_and_load_round_trip(self): + lock = make_lock() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "mcp.lock.json" + write_lock(path, lock) + self.assertEqual(load_lock(path), lock) + self.assertEqual(path.read_bytes(), serialize_lock(lock)) + + def test_replace_failure_preserves_old_file_and_removes_temporary(self): + lock = make_lock() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "mcp.lock.json" + path.write_text("original", encoding="utf-8") + with patch("mcplock.contract.os.replace", side_effect=OSError("denied")): + with self.assertRaises(OSError): + write_lock(path, lock) + self.assertEqual(path.read_text(encoding="utf-8"), "original") + self.assertEqual(list(Path(directory).glob(".mcp.lock.json.*")), []) + + def test_load_rejects_bad_json_version_and_stats(self): + cases = [ + b"not-json", + b"\xff", + b'{"lockVersion":2}\n', + serialize_lock({ + **make_lock(), + "stats": {"definitionBytes": 0, "toolCount": 1}, + }), + ] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "mcp.lock.json" + for content in cases: + with self.subTest(content=content): + path.write_bytes(content) + with self.assertRaises(ContractError): + load_lock(path) + + if __name__ == "__main__": unittest.main() From 44d8b3d149d494ef79ee133f77e13d9c24881071 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 12:54:06 +0700 Subject: [PATCH 07/10] fix: validate lock types and command separator --- src/mcplock/cli.py | 4 +--- src/mcplock/contract.py | 12 +++++++++++- tests/test_cli.py | 27 ++++++++++++++++++++++++++- tests/test_contract.py | 25 +++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/mcplock/cli.py b/src/mcplock/cli.py index dbfd91b..6da4459 100644 --- a/src/mcplock/cli.py +++ b/src/mcplock/cli.py @@ -61,9 +61,7 @@ def render_changes(changes): def main(argv=None): args = parser().parse_args(argv) command = list(args.server_command) - if command and command[0] == "--": - command.pop(0) - if not command: + if not command or command.pop(0) != "--" or not command: print("MCPLock error: server command is required after --", file=sys.stderr) return 2 try: diff --git a/src/mcplock/contract.py b/src/mcplock/contract.py index c425dcf..2e6bbe0 100644 --- a/src/mcplock/contract.py +++ b/src/mcplock/contract.py @@ -150,11 +150,21 @@ def load_lock(path): lock = json.loads(raw, parse_constant=_reject_constant) except (json.JSONDecodeError, ValueError) as exc: raise ContractError(f"invalid lockfile JSON: {path}") from exc - if not isinstance(lock, dict) or lock.get("lockVersion") != LOCK_VERSION: + if ( + not isinstance(lock, dict) + or type(lock.get("lockVersion")) is not int + or lock["lockVersion"] != LOCK_VERSION + ): raise ContractError(f"unsupported lockfile version in {path}") required = {"protocolVersion", "server", "stats", "tools"} if not required.issubset(lock): raise ContractError(f"lockfile is missing required fields: {path}") + stats = lock["stats"] + if not isinstance(stats, dict) or any( + type(stats.get(key)) is not int + for key in ("definitionBytes", "toolCount") + ): + raise ContractError(f"invalid lockfile statistics: {path}") try: rebuilt = build_lock(lock["protocolVersion"], lock["server"], lock["tools"]) except (KeyError, TypeError, ContractError) as exc: diff --git a/tests/test_cli.py b/tests/test_cli.py index 5388240..aa76e75 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,7 @@ import warnings from mcplock.cli import main -from mcplock.contract import build_lock, write_lock +from mcplock.contract import build_lock, serialize_lock, write_lock FAKE_SERVER = Path(__file__).with_name("fake_server.py") @@ -93,6 +93,31 @@ def test_unsupported_lock_protocol_exits_two(self): self.assertEqual(code, 2) self.assertIn("unsupported lockfile protocol version", error) + def test_malformed_integer_lock_exits_two(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "mcp.lock.json" + malformed = build_lock( + "2025-11-25", + {"name": "fixture", "version": "1.0.0"}, + [{"name": "read_file", "inputSchema": {"type": "object"}}], + ) + malformed["stats"]["toolCount"] = True + path.write_bytes(serialize_lock(malformed)) + code, output, error = self.invoke( + ["check", "--lock", str(path), "--", *server("baseline")] + ) + self.assertEqual((code, output), (2, "")) + self.assertIn("invalid lockfile", error) + + def test_server_command_requires_separator(self): + for action in ("update", "check"): + with self.subTest(action=action): + with patch("mcplock.cli.discover") as discover: + code, output, error = self.invoke([action, "fixture"]) + discover.assert_not_called() + self.assertEqual((code, output), (2, "")) + self.assertIn("server command is required after --", error) + def test_warning_only_check_exits_zero(self): with tempfile.TemporaryDirectory() as directory: lock = str(Path(directory) / "mcp.lock.json") diff --git a/tests/test_contract.py b/tests/test_contract.py index 4a47458..7d29d9f 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -344,6 +344,31 @@ def test_load_rejects_bad_json_version_and_stats(self): with self.assertRaises(ContractError): load_lock(path) + def test_load_rejects_non_integer_versions_and_stats(self): + lock = make_lock() + cases = [ + {**lock, "lockVersion": True}, + {**lock, "lockVersion": 1.0}, + { + **lock, + "stats": {**lock["stats"], "toolCount": True}, + }, + { + **lock, + "stats": { + **lock["stats"], + "definitionBytes": float(lock["stats"]["definitionBytes"]), + }, + }, + ] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "mcp.lock.json" + for malformed in cases: + with self.subTest(malformed=malformed): + path.write_bytes(serialize_lock(malformed)) + with self.assertRaises(ContractError): + load_lock(path) + if __name__ == "__main__": unittest.main() From f82dade1b0daef573f2968d9cbe8e1e57c56060f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 13:05:03 +0700 Subject: [PATCH 08/10] docs: prepare MCPLock open-source release --- .github/workflows/test.yml | 25 +++++++++++++++ .gitignore | 7 +++++ CONTRIBUTING.md | 13 ++++++++ LICENSE | 21 +++++++++++++ README.md | 63 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 6 files changed, 130 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f94de46 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: test + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.11", "3.14"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install -e . --no-deps + - run: python -m unittest discover -s tests -v + - run: python -m mcplock --help diff --git a/.gitignore b/.gitignore index 3a0cb31..d977e27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ /.worktrees/ /.superpowers/ +__pycache__/ +*.py[cod] +.venv/ +build/ +dist/ +*.egg-info/ +/mcp.lock.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9174b82 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing + +MCPLock intentionally keeps a small standard-library core. + +1. Use Python 3.11 or newer. +2. Install with `python -m pip install -e . --no-deps`. +3. Add a failing `unittest` for every behavior change. +4. Run `python -m unittest discover -s tests -v`. +5. Keep runtime dependencies at zero and tests offline. + +Bug reports should include the MCPLock command, exit code, sanitized stderr, +operating system, Python version, and a minimal MCP server fixture when +possible. Never include credentials or an unsanitized environment. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..224a631 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MCPLock contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fe8461 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# MCPLock + +> Catch breaking MCP tool changes before your agents do. + +MCPLock creates a Git-friendly lockfile for one stdio MCP server's `tools/list` +contract. Run it again in CI to catch removed tools, newly required inputs, +narrowed types, removed enum values, and forbidden additional properties. + +- No model or API key +- No service, daemon, database, or telemetry +- No runtime dependencies +- Deterministic JSON suitable for code review + +## One-minute offline demo + +~~~console +python -m pip install -e . --no-deps +mcplock update -- python tests/fake_server.py --scenario baseline +mcplock check -- python tests/fake_server.py --scenario breaking +~~~ + +The second command exits `1` and identifies the new required input. The first +command writes `mcp.lock.json`; commit that file beside your MCP server. + +## Use it with your server + +~~~console +mcplock update -- python my_server.py +git add mcp.lock.json +mcplock check -- python my_server.py +~~~ + +Use `--lock path/to/name.lock.json` for multiple servers and `--timeout 60` +for slow startup. MCPLock passes every token after `--` directly to the child +process without invoking a shell. + +## CI + +~~~yaml +- name: Check MCP tool contract + run: mcplock check -- python my_server.py +~~~ + +Exit codes are `0` for compatible or warning-only changes, `1` for certain +breaking changes, `2` for usage/lockfile/launch/protocol failures, and `130` +for user interruption. + +## What counts as breaking + +MCPLock fails for a removed tool, newly required input, removed input +property, narrowed JSON type, removed enum value, or a change from allowed +additional properties to forbidden. Every other observed contract change is +shown for review without claiming complete JSON Schema analysis. + +## Scope + +Version 0.1 supports stdio MCP revisions `2025-11-25`, `2025-06-18`, +`2025-03-26`, and `2024-11-05`. It does not call tools, connect over HTTP, +scan for vulnerabilities, use models, or upload data. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). MCPLock is MIT licensed. diff --git a/pyproject.toml b/pyproject.toml index 0ac242e..d28fdbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ build-backend = "hatchling.build" name = "mcplock" version = "0.1.0" description = "Catch breaking MCP tool changes before your agents do" +readme = "README.md" requires-python = ">=3.11" license = "MIT" keywords = ["ai-agents", "mcp", "model-context-protocol", "testing"] From d599503565579d8d7b3e4d583973a9280addc94c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 13:55:45 +0700 Subject: [PATCH 09/10] fix: honor JSON Schema numeric type hierarchy --- src/mcplock/contract.py | 10 ++++++---- tests/test_contract.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/mcplock/contract.py b/src/mcplock/contract.py index 2e6bbe0..bcb1764 100644 --- a/src/mcplock/contract.py +++ b/src/mcplock/contract.py @@ -201,10 +201,12 @@ def _types(schema): if value is _MISSING: return _ALL_TYPES if isinstance(value, str): - return frozenset({value}) - if isinstance(value, list) and all(isinstance(item, str) for item in value): - return frozenset(value) - return None + result = frozenset({value}) + elif isinstance(value, list) and all(isinstance(item, str) for item in value): + result = frozenset(value) + else: + return None + return result | {"integer"} if "number" in result else result def _strings(value): diff --git a/tests/test_contract.py b/tests/test_contract.py index 7d29d9f..cc01612 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -245,6 +245,42 @@ def test_reverse_changes_are_informational(self): message, ) + def test_integer_to_number_is_informational_broadening(self): + baseline_tool = make_tool() + baseline_tool["inputSchema"]["properties"]["encoding"]["type"] = "integer" + current_tool = make_tool() + current_tool["inputSchema"]["properties"]["encoding"]["type"] = "number" + type_path = "tools.read_file.inputSchema.properties.encoding.type" + + changes = compare_locks(make_lock(baseline_tool), make_lock(current_tool)) + + self.assertEqual( + [change for change in changes if change.path != "stats.definitionBytes"], + [Change("info", type_path, "accepted JSON types broadened")], + ) + self.assertEqual( + [ + (change.severity, change.path) + for change in changes + if change.path == "stats.definitionBytes" + ], + [("info", "stats.definitionBytes")], + ) + + def test_number_to_integer_is_breaking_narrowing(self): + baseline_tool = make_tool() + baseline_tool["inputSchema"]["properties"]["encoding"]["type"] = "number" + current_tool = make_tool() + current_tool["inputSchema"]["properties"]["encoding"]["type"] = "integer" + type_path = "tools.read_file.inputSchema.properties.encoding.type" + + changes = compare_locks(make_lock(baseline_tool), make_lock(current_tool)) + + self.assertEqual( + changes, + [Change("breaking", type_path, "accepted JSON types narrowed")], + ) + def test_uncertain_metadata_schema_output_and_server_changes_warn(self): changed = make_tool() changed["description"] = "Changed guidance" From 238b61a5105af5e02bc77bef91cd4732d834db4b Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 14:07:34 +0700 Subject: [PATCH 10/10] fix: harden MCP stdio request boundaries --- src/mcplock/cli.py | 3 +- src/mcplock/stdio.py | 76 +++++++++++++++++++++++++------------------- tests/fake_server.py | 8 ++++- tests/test_cli.py | 12 ++++++- tests/test_stdio.py | 33 +++++++++++++++++++ 5 files changed, 97 insertions(+), 35 deletions(-) diff --git a/src/mcplock/cli.py b/src/mcplock/cli.py index 6da4459..a118271 100644 --- a/src/mcplock/cli.py +++ b/src/mcplock/cli.py @@ -2,6 +2,7 @@ import argparse import asyncio +import math from pathlib import Path import sys @@ -17,7 +18,7 @@ def positive_timeout(value): timeout = float(value) - if timeout <= 0: + if not math.isfinite(timeout) or timeout <= 0: raise argparse.ArgumentTypeError("timeout must be greater than zero") return timeout diff --git a/src/mcplock/stdio.py b/src/mcplock/stdio.py index be58645..4d19605 100644 --- a/src/mcplock/stdio.py +++ b/src/mcplock/stdio.py @@ -4,6 +4,7 @@ from contextlib import suppress from dataclasses import dataclass import json +import math from typing import Any, Sequence SUPPORTED_PROTOCOL_VERSIONS = frozenset({ @@ -52,12 +53,7 @@ async def _send(self, message): async def _read(self): try: - line = await asyncio.wait_for( - self.process.stdout.readline(), - timeout=self.timeout, - ) - except asyncio.TimeoutError as exc: - raise DiscoveryError("MCP request timed out") from exc + line = await self.process.stdout.readline() except ValueError as exc: raise DiscoveryError("MCP message exceeds 16 MiB") from exc if not line: @@ -79,7 +75,11 @@ async def _read(self): return message async def notify(self, method): - await self._send({"jsonrpc": "2.0", "method": method}) + try: + async with asyncio.timeout(self.timeout): + await self._send({"jsonrpc": "2.0", "method": method}) + except TimeoutError as exc: + raise DiscoveryError("MCP request timed out") from exc async def request(self, method, params=None): request_id = self.next_id @@ -87,30 +87,42 @@ async def request(self, method, params=None): request = {"jsonrpc": "2.0", "id": request_id, "method": method} if params is not None: request["params"] = params - await self._send(request) - while True: - response = await self._read() - if "method" in response and "id" not in response: - continue - if "method" in response and "id" in response: - await self._send({ - "jsonrpc": "2.0", - "id": response["id"], - "error": {"code": -32601, "message": "Method not found"}, - }) - continue - if response.get("id") != request_id: - raise DiscoveryError("MCP response used an unexpected request id") - if "error" in response: - error = response["error"] - text = error.get("message", "unknown error") if isinstance( - error, dict - ) else str(error) - raise DiscoveryError(f"MCP {method} failed: {text}") - result = response.get("result") - if not isinstance(result, dict): - raise DiscoveryError(f"MCP {method} returned a non-object result") - return result + try: + async with asyncio.timeout(self.timeout): + await self._send(request) + while True: + response = await self._read() + if "method" in response and "id" not in response: + continue + if "method" in response and "id" in response: + await self._send({ + "jsonrpc": "2.0", + "id": response["id"], + "error": { + "code": -32601, + "message": "Method not found", + }, + }) + continue + response_id = response.get("id") + if type(response_id) is not int or response_id != request_id: + raise DiscoveryError( + "MCP response used an unexpected request id" + ) + if "error" in response: + error = response["error"] + text = error.get( + "message", "unknown error" + ) if isinstance(error, dict) else str(error) + raise DiscoveryError(f"MCP {method} failed: {text}") + result = response.get("result") + if not isinstance(result, dict): + raise DiscoveryError( + f"MCP {method} returned a non-object result" + ) + return result + except TimeoutError as exc: + raise DiscoveryError("MCP request timed out") from exc async def _drain_stderr(stream, tail): @@ -142,7 +154,7 @@ async def _shutdown(process): async def discover(command: Sequence[str], timeout: float = 30.0) -> Discovery: if not command: raise DiscoveryError("server command is required") - if timeout <= 0: + if not math.isfinite(timeout) or timeout <= 0: raise DiscoveryError("timeout must be greater than zero") try: process = await asyncio.create_subprocess_exec( diff --git a/tests/fake_server.py b/tests/fake_server.py index 6d19a8c..baf7528 100644 --- a/tests/fake_server.py +++ b/tests/fake_server.py @@ -40,6 +40,8 @@ def main(): "pagination", "legacy", "notification", + "notification-stream", + "boolean-id", "client-request", "duplicate", "cycle", @@ -95,6 +97,10 @@ def main(): initialize = receive() if args.scenario == "notification": send({"jsonrpc": "2.0", "method": "notifications/progress"}) + if args.scenario == "notification-stream": + for _ in range(22): + send({"jsonrpc": "2.0", "method": "notifications/progress"}) + time.sleep(0.05) if args.scenario == "client-request": send({"jsonrpc": "2.0", "id": 900, "method": "roots/list"}) rejection = receive() @@ -110,7 +116,7 @@ def main(): capabilities = {} if args.scenario == "missing-tools" else {"tools": {}} send({ "jsonrpc": "2.0", - "id": initialize["id"], + "id": True if args.scenario == "boolean-id" else initialize["id"], "result": { "protocolVersion": version, "capabilities": capabilities, diff --git a/tests/test_cli.py b/tests/test_cli.py index aa76e75..0c6a25b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import argparse from contextlib import redirect_stderr, redirect_stdout import gc from io import StringIO @@ -8,7 +9,7 @@ from unittest.mock import patch import warnings -from mcplock.cli import main +from mcplock.cli import main, positive_timeout from mcplock.contract import build_lock, serialize_lock, write_lock FAKE_SERVER = Path(__file__).with_name("fake_server.py") @@ -25,6 +26,15 @@ def invoke(self, args): code = main(args) return code, stdout.getvalue(), stderr.getvalue() + def test_positive_timeout_rejects_non_finite_values(self): + for value in ("nan", "inf", "-inf"): + with self.subTest(value=value): + with self.assertRaisesRegex( + argparse.ArgumentTypeError, + "timeout must be greater than zero", + ): + positive_timeout(value) + def test_update_then_compatible_check(self): with tempfile.TemporaryDirectory() as directory: lock = str(Path(directory) / "mcp.lock.json") diff --git a/tests/test_stdio.py b/tests/test_stdio.py index b1d8e3a..ff692c8 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -2,6 +2,7 @@ from pathlib import Path import sys import unittest +from unittest.mock import patch from mcplock.stdio import ( Discovery, @@ -66,6 +67,12 @@ def test_notifications_and_rejected_client_requests_continue(self): ["read_file"], ) + def test_response_id_requires_an_exact_integer(self): + self.assert_discovery_error( + "boolean-id", + "unexpected request id", + ) + def test_protocol_failures_are_actionable(self): cases = { "duplicate": "duplicate tool names", @@ -127,12 +134,38 @@ def test_timeout_returns_after_child_cleanup(self): finally: loop.close() + def test_request_timeout_is_one_total_deadline(self): + async def run(): + return await asyncio.wait_for( + discover(command("notification-stream"), timeout=1.0), + timeout=5.0, + ) + + with self.assertRaisesRegex(DiscoveryError, "timed out"): + asyncio.run(run()) + def test_arguments_are_validated_before_launch(self): with self.assertRaisesRegex(DiscoveryError, "command is required"): asyncio.run(discover([], timeout=1)) with self.assertRaisesRegex(DiscoveryError, "greater than zero"): asyncio.run(discover(command("baseline"), timeout=0)) + def test_non_finite_timeouts_are_validated_before_launch(self): + for timeout in (float("nan"), float("inf"), float("-inf")): + with self.subTest(timeout=timeout): + with patch( + "mcplock.stdio.asyncio.create_subprocess_exec", + side_effect=OSError("launch attempted"), + ) as launch: + with self.assertRaisesRegex( + DiscoveryError, + "timeout must be greater than zero", + ): + asyncio.run( + discover(command("baseline"), timeout=timeout) + ) + launch.assert_not_called() + if __name__ == "__main__": unittest.main()