From 73ffb99e7920dacfc1166c1554b91dfdad4898a5 Mon Sep 17 00:00:00 2001 From: Frederick Daell Lied Diaz Date: Sun, 2 Aug 2026 14:48:08 -0400 Subject: [PATCH 01/25] feat: use PowerShell 7 for Windows commands --- .github/workflows/compliance.yml | 4 +- README.md | 14 +- coding_tools_mcp/processes.py | 154 ++++++++++++++++++++++ coding_tools_mcp/server.py | 16 ++- docs/limitations.md | 5 + docs/runtime-contract-v0.2.md | 2 +- tests/test_windows_pwsh.py | 211 +++++++++++++++++++++++++++++++ uv.lock | 59 ++++++++- 8 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 tests/test_windows_pwsh.py diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index faeb9a2..6445449 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -123,4 +123,6 @@ jobs: run: | call "%VCVARSALL%" x64 where cl.exe - python -m unittest tests.compliance.test_windows_msvc_smoke + python -m unittest ^ + tests.compliance.test_windows_msvc_smoke ^ + tests.test_windows_pwsh diff --git a/README.md b/README.md index a178309..9a35062 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,12 @@ compatibility). A one-line installer, per-client walkthroughs, and troubleshooting live in [docs/quickstart.md](docs/quickstart.md) and [docs/mcp-client-config.md](docs/mcp-client-config.md). +On Windows, string commands run through PowerShell 7 (`pwsh`) with +`-NoLogo -NoProfile -NonInteractive`. Set +`CODING_TOOLS_MCP_PWSH_PATH` to an absolute trusted `pwsh.exe` path to pin +the launcher. The server reports `SHELL_NOT_FOUND` or +`SHELL_VERSION_UNSUPPORTED` instead of falling back to `cmd.exe`. + ## Seven things to try **1. Make Claude Desktop your coding agent.** The config above is all it @@ -116,9 +122,11 @@ Per-workspace profiles, server and tunnel start/stop, credential setup with clipboard helpers, live health checks. English and 简体中文. **6. Keep an interactive command alive.** `exec_command` starts a REPL or -debugger under a real PTY; `write_stdin` feeds it across turns; `read_output` -pages long output; `kill_command` cleans up. Long-running processes are -first-class, with deadline watchdogs and bounded buffers. +debugger under a real POSIX PTY; `write_stdin` feeds it across turns; +`read_output` pages long output; `kill_command` cleans up. Long-running +processes are first-class, with deadline watchdogs and bounded buffers. +Windows uses PowerShell 7 for non-TTY commands; ConPTY remains a separate +limitation. **7. Give your own agent production-grade hands.** Building an agent loop with the Anthropic SDK or anything else? Don't hand-roll file and exec tools — diff --git a/coding_tools_mcp/processes.py b/coding_tools_mcp/processes.py index 382f019..21497e6 100644 --- a/coding_tools_mcp/processes.py +++ b/coding_tools_mcp/processes.py @@ -1,10 +1,13 @@ from __future__ import annotations +import functools +import ntpath import os import signal import subprocess import threading import time +from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any, BinaryIO @@ -14,6 +17,154 @@ COMMAND_BUFFER_BYTES = 524_288 HARD_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) +PWSH_PATH_ENV = "CODING_TOOLS_MCP_PWSH_PATH" + + +def _environment_value(env: Mapping[str, str], target: str) -> str | None: + target_upper = target.upper() + for name, value in env.items(): + if name.upper() == target_upper: + return value + return None + + +def _pwsh_probe_environment() -> dict[str, str]: + """Build a minimal host-derived environment without server secrets.""" + + allowed = ("PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "COMSPEC") + result: dict[str, str] = {} + for name in allowed: + value = _environment_value(os.environ, name) + if value is not None: + result[name] = value + return result + + +def _find_windows_executable_on_path(filename: str, path: str | None) -> str | None: + """Search absolute PATH entries without Windows' implicit current-directory lookup.""" + + if not path: + return None + current_dir = ntpath.normcase(ntpath.abspath(os.getcwd())) + for raw_entry in path.split(";"): + entry = ntpath.expandvars(raw_entry.strip().strip('"')) + if not entry or not ntpath.isabs(entry): + continue + normalized_entry = ntpath.normcase(ntpath.abspath(entry)) + try: + if ntpath.commonpath((current_dir, normalized_entry)) == current_dir: + continue + except ValueError: + pass + candidate = ntpath.normpath(ntpath.join(entry, filename)) + if os.path.isfile(candidate): + return candidate + return None + + +def resolve_pwsh() -> str: + """Resolve PowerShell 7 only from the trusted server process environment.""" + + executable: str | None + configured = (_environment_value(os.environ, PWSH_PATH_ENV) or "").strip().strip('"') + if configured: + executable = ntpath.normpath(ntpath.expandvars(configured)) + if ( + not ntpath.isabs(executable) + or ntpath.basename(executable).lower() != "pwsh.exe" + or not os.path.isfile(executable) + ): + raise ToolFailure( + "SHELL_NOT_FOUND", + f"{PWSH_PATH_ENV} must point to an existing absolute pwsh.exe path.", + category="runtime", + details={"executable": executable, "environment_variable": PWSH_PATH_ENV}, + ) + else: + executable = _find_windows_executable_on_path( + "pwsh.exe", + _environment_value(os.environ, "PATH"), + ) + if executable is None: + raise ToolFailure( + "SHELL_NOT_FOUND", + "PowerShell 7 is required for Windows string commands, but pwsh was not found on PATH.", + category="runtime", + details={ + "executable": "pwsh", + "retry_hint": ( + "Install PowerShell 7 and add pwsh to PATH, or set " + f"{PWSH_PATH_ENV} to its absolute path." + ), + }, + ) + major = pwsh_major_version(executable) + if major < 7: + raise ToolFailure( + "SHELL_VERSION_UNSUPPORTED", + f"PowerShell 7 or newer is required; resolved major version {major}.", + category="runtime", + details={"executable": executable, "major_version": major, "required_major_version": 7}, + ) + return executable + + +@functools.lru_cache(maxsize=16) +def pwsh_major_version(executable: str) -> int: + """Return and cache the major version reported by a pwsh executable.""" + + try: + completed = subprocess.run( + [ + executable, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$PSVersionTable.PSVersion.Major", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=5, + check=False, + env=_pwsh_probe_environment(), + cwd=ntpath.dirname(executable) or None, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ToolFailure( + "SHELL_VERSION_UNSUPPORTED", + "PowerShell version could not be verified.", + category="runtime", + details={"executable": executable, "reason": str(exc)}, + ) from exc + stdout = completed.stdout.strip() + if completed.returncode != 0 or not stdout.isdigit(): + raise ToolFailure( + "SHELL_VERSION_UNSUPPORTED", + "PowerShell version could not be verified.", + category="runtime", + details={ + "executable": executable, + "exit_code": completed.returncode, + "stderr": completed.stderr.strip()[:500], + }, + ) + return int(stdout) + + +def build_pwsh_argv(executable: str, command: str) -> list[str]: + """Build a deterministic non-interactive PowerShell invocation.""" + + return [ + executable, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + command, + ] def terminate_process_group( @@ -68,6 +219,9 @@ def spawn_process( """Spawn a pipe-backed or true POSIX PTY-backed process.""" if not tty: + if os.name == "nt" and shell and isinstance(command, str): + command = build_pwsh_argv(resolve_pwsh(), command) + shell = False process = subprocess.Popen( command, cwd=cwd, diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index dfe3c71..c021f75 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -178,11 +178,22 @@ class ModeCapabilities: r"(https?://|urllib\.request|urllib3|requests\.|http\.client|\bHTTPConnection\b|\bHTTPSConnection\b|socket\.|aiohttp|httpx|\bcurl\b|\bwget\b|\bnc\b|\bnetcat\b|\bssh\b|\bscp\b|\bftp\b)", re.I, ) +POWERSHELL_NETWORK_RE = re.compile( + r"(?:^|[;&|{}\r\n])\s*(?:Invoke-WebRequest|Invoke-RestMethod|Start-BitsTransfer|" + r"Test-NetConnection|Test-Connection|Resolve-DnsName|iwr|irm|tnc|ping(?:\.exe)?|" + r"nslookup(?:\.exe)?|tracert(?:\.exe)?)\b|(?:System\.)?Net\.", + re.I, +) SHELL_EXPANSION_RE = re.compile(r"(`|\$\(|\$\{)") DESTRUCTIVE_RE = re.compile( r"(^|\s)(sudo|su|chmod\s+-R|chown\s+-R|mkfs|mount|umount|find\b[^;&|]*\s-delete\b|git\b[^;&|]*\breset\s+--hard\b|git\b[^;&|]*\bclean\s+-[^\s]*[fx][^\s]*|rm\s+-[^\s]*r[^\s]*f|rm\s+-[^\s]*f[^\s]*r)\b", re.I, ) +POWERSHELL_DESTRUCTIVE_RE = re.compile( + r"(?:^|[;&|{}\r\n])\s*(?:Remove-Item|rm|ri|del|erase|rmdir|rd)\b" + r"(?=[^;&|{}\r\n]*\s-(?:r|re|rec|recu|recur|recurs|recurse)\b)", + re.I, +) MAX_HTTP_REQUEST_BYTES = 1_048_576 EXEC_PREVIEW_BYTES = 4096 MAX_ACTIVE_COMMANDS = 16 @@ -2470,14 +2481,15 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: category="permission", details={"permission": "destructive_command", "command": compact}, ) - if DESTRUCTIVE_RE.search(cmd): + if DESTRUCTIVE_RE.search(cmd) or POWERSHELL_DESTRUCTIVE_RE.search(cmd): raise ToolFailure( "PERMISSION_REQUIRED", "Destructive commands are blocked without explicit permission.", category="permission", details={"permission": "destructive_command", "command": compact}, ) - if not self.allow_network and NETWORK_RE.search(cmd) and not is_literal_network_reference_command(cmd): + network_command = NETWORK_RE.search(cmd) or POWERSHELL_NETWORK_RE.search(cmd) + if not self.allow_network and network_command and not is_literal_network_reference_command(cmd): raise ToolFailure( "PERMISSION_REQUIRED", "Network access is denied by default.", diff --git a/docs/limitations.md b/docs/limitations.md index 3948697..c391d22 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -6,6 +6,11 @@ - Non-Linux platforms or Linux kernels without Landlock are not production targets for `exec_command` without an external sandbox. - This build uses real POSIX PTYs but does not implement Windows ConPTY; `tty=true` returns `TTY_UNSUPPORTED` on Windows. +- Windows string commands require PowerShell 7 (`pwsh`) and run with + `-NoLogo -NoProfile -NonInteractive`. Operators may pin an absolute trusted + executable with `CODING_TOOLS_MCP_PWSH_PATH`; otherwise the server searches + absolute entries on its own process `PATH` while excluding the current + directory tree. There is no `cmd.exe` fallback. - Portable filesystems do not provide a transaction across unrelated directories. `apply_patch` keeps same-directory backups and rolls back the full staged set, but a storage failure that also prevents rollback is surfaced diff --git a/docs/runtime-contract-v0.2.md b/docs/runtime-contract-v0.2.md index fabbf9b..8f4b60b 100644 --- a/docs/runtime-contract-v0.2.md +++ b/docs/runtime-contract-v0.2.md @@ -112,7 +112,7 @@ Tool failures keep the same envelope with `isError: true`, a readable error in Known tool error codes include: ```json -["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] +["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "SHELL_NOT_FOUND", "SHELL_VERSION_UNSUPPORTED", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] ``` Error categories are `validation`, `security`, `permission`, `runtime`, diff --git a/tests/test_windows_pwsh.py b/tests/test_windows_pwsh.py new file mode 100644 index 0000000..1cc33ae --- /dev/null +++ b/tests/test_windows_pwsh.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from coding_tools_mcp import processes +from coding_tools_mcp.errors import ToolFailure +from coding_tools_mcp.server import Runtime, ShellEnvPolicy + + +class WindowsPowerShellSpawnTests(unittest.TestCase): + def tearDown(self) -> None: + processes.pwsh_major_version.cache_clear() + + def test_windows_string_command_uses_server_resolved_noninteractive_pwsh(self) -> None: + captured: dict[str, object] = {} + + class FakeProcess: + pass + + def fake_popen(command: object, **kwargs: object) -> FakeProcess: + captured["command"] = command + captured.update(kwargs) + return FakeProcess() + + trusted = r"C:\Program Files\PowerShell\7\pwsh.exe" + trusted_path = r"C:\Program Files\PowerShell\7;C:\Windows\System32" + attacker_path = r"C:\workspace\bin" + + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict(processes.os.environ, {"Path": trusted_path}, clear=True), + patch.object(processes.os.path, "isfile", side_effect=lambda path: path == trusted), + patch.object(processes, "pwsh_major_version", return_value=7), + patch.object(processes.subprocess, "Popen", side_effect=fake_popen), + ): + process, pty_fd = processes.spawn_process( + "Write-Output 'ok'", + cwd=r"C:\workspace", + shell=True, + env={"Path": attacker_path}, + tty=False, + popen_kwargs={}, + ) + + self.assertIsInstance(process, FakeProcess) + self.assertIsNone(pty_fd) + self.assertEqual( + captured["command"], + [ + trusted, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Write-Output 'ok'", + ], + ) + self.assertIs(captured["shell"], False) + self.assertEqual(captured["env"], {"Path": attacker_path}) + + def test_windows_pwsh_resolution_skips_current_directory_entries(self) -> None: + trusted = r"C:\Program Files\PowerShell\7\pwsh.exe" + malicious = r"C:\workspace\pwsh.exe" + + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\workspace"), + patch.dict( + processes.os.environ, + {"Path": r".;C:\workspace;C:\Program Files\PowerShell\7"}, + clear=True, + ), + patch.object( + processes.os.path, + "isfile", + side_effect=lambda path: path in {trusted, malicious}, + ), + patch.object(processes, "pwsh_major_version", return_value=7), + ): + self.assertEqual(processes.resolve_pwsh(), trusted) + + def test_windows_pwsh_rejects_invalid_explicit_path(self) -> None: + with ( + patch.object(processes.os, "name", "nt"), + patch.dict( + processes.os.environ, + {processes.PWSH_PATH_ENV: r".\pwsh.exe"}, + clear=True, + ), + patch.object(processes.os.path, "isfile", return_value=True), + ): + with self.assertRaises(ToolFailure) as raised: + processes.resolve_pwsh() + + self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") + + def test_windows_string_command_requires_pwsh(self) -> None: + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict(processes.os.environ, {"Path": r"C:\Windows\System32"}, clear=True), + patch.object(processes.os.path, "isfile", return_value=False), + ): + with self.assertRaises(ToolFailure) as raised: + processes.resolve_pwsh() + + self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") + + def test_windows_string_command_rejects_powershell_older_than_7(self) -> None: + executable = r"C:\PowerShell\6\pwsh.exe" + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict(processes.os.environ, {"Path": r"C:\PowerShell\6"}, clear=True), + patch.object(processes.os.path, "isfile", side_effect=lambda path: path == executable), + patch.object(processes, "pwsh_major_version", return_value=6), + ): + with self.assertRaises(ToolFailure) as raised: + processes.resolve_pwsh() + + self.assertEqual(raised.exception.code, "SHELL_VERSION_UNSUPPORTED") + + @unittest.skipUnless(os.name == "nt", "requires Windows PowerShell 7") + def test_runtime_executes_bare_powershell_syntax(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime( + Path(tmp), + permission_mode="trusted", + shell_env_policy=ShellEnvPolicy(inherit="all"), + ) + try: + result = runtime.exec_command( + { + "cmd": "Write-Output ('PS_MAJOR=' + $PSVersionTable.PSVersion.Major)", + "timeout_ms": 30_000, + "yield_time_ms": 30_000, + "verbosity": "full", + } + ) + finally: + runtime.close() + + self.assertEqual(result.get("status"), "exited", result) + self.assertEqual(result.get("exit_code"), 0, result) + self.assertIn("PS_MAJOR=7", str(result.get("stdout", ""))) + + +class WindowsPowerShellPolicyTests(unittest.TestCase): + def test_safe_mode_blocks_powershell_network_commands_without_url_literals(self) -> None: + commands = ( + "Invoke-WebRequest -Uri example.com", + "Invoke-RestMethod -Uri api.example.com", + "iwr example.com", + "irm api.example.com", + "Start-BitsTransfer -Source example.com -Destination out.bin", + "New-Object System.Net.WebClient", + "Test-NetConnection example.com", + "Test-Connection example.com", + "ping example.com", + "tnc example.com", + "Resolve-DnsName example.com", + "[System.Net.Dns]::GetHostAddresses('example.com')", + "Write-Output ok\nInvoke-WebRequest -Uri example.com", + ) + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode="safe") + try: + for command in commands: + with self.subTest(command=command): + with self.assertRaises(ToolFailure) as raised: + runtime._check_command_policy(command, {}) + self.assertEqual(raised.exception.details.get("permission"), "network") + finally: + runtime.close() + + def test_safe_mode_blocks_recursive_powershell_deletion_aliases_and_abbreviations(self) -> None: + commands = ( + "Remove-Item -Recurse .", + "Remove-Item -Recurse -Force .", + "Remove-Item -Force -LiteralPath . -Recurse", + "Remove-Item -Recu -For .", + "rm -r .", + "rm -Recurse -Force .", + "ri -Recu .", + "ri -Force -Recurse .", + "del -Recurse .", + "rmdir -r .", + "Write-Output ok\r\nRemove-Item -Recurse .", + ) + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode="safe") + try: + for command in commands: + with self.subTest(command=command): + with self.assertRaises(ToolFailure) as raised: + runtime._check_command_policy(command, {}) + self.assertEqual( + raised.exception.details.get("permission"), + "destructive_command", + ) + finally: + runtime.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index a6bffa2..bb03c28 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ wheels = [ [[package]] name = "coding-tools-mcp" -version = "0.2.0" +version = "0.2.2" source = { editable = "." } dependencies = [ { name = "pyjwt" }, @@ -61,6 +61,7 @@ desktop = [ ] dev = [ { name = "mypy" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "typing-extensions" }, ] @@ -75,6 +76,7 @@ requires-dist = [ { name = "psutil", marker = "extra == 'desktop'", specifier = ">=7.0,<8" }, { name = "pyjwt", specifier = ">=2.8" }, { name = "pyside6", marker = "extra == 'desktop'", specifier = ">=6.8,<6.9" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15,<0.16" }, { name = "typing-extensions", marker = "extra == 'dev'", specifier = ">=4.12" }, ] @@ -391,6 +393,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0f/5d8c6da7586e57ee032643e0c0e62335ef1a1add1a980160ddd1654f1d8d/PySide6_Essentials-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07", size = 72191029, upload-time = "2025-03-27T12:15:10.425Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.15.17" From c435228cfade8a359095290dde8e1b1fd135f056 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:59:40 +0000 Subject: [PATCH 02/25] Declare kill_wait_ms in kill_command schema and drop legacy full output ref kill_command reads kill_wait_ms for the hard-kill escalation wait, but the input schema never declared it, so schema validation (additionalProperties: false) rejected any MCP call that passed it. Tests only passed because they called the runtime directly; the kill test now runs its arguments through validate_arguments first. read_output accepted an undocumented command::full reference that silently read stdout only while the validation error claimed only stdout/stderr forms exist. The server never emits full refs and the command: prefix is new in this release, so no client can hold one; reject it instead of keeping the misleading alias. Co-authored-by: Cursor --- CHANGELOG.md | 6 ++++++ coding_tools_mcp/server.py | 10 ++++------ docs/runtime-contract-v0.2.md | 5 ++++- tests/compliance/test_runtime_helpers.py | 10 +++++++++- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7730583..7b6b34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ reconnect. Tool descriptions now direct remote clients to pass explicit `path`/`workdir` arguments and include concrete examples for patching and command continuation. +- `kill_command` now declares `kill_wait_ms` (hard-kill escalation wait, + default 2000 ms) in its input schema; previously the runtime honored it but + schema validation rejected any call that passed it. +- `read_output` no longer accepts the undocumented `command::full` + reference form, which silently read stdout only. Use the per-stream + `command::stdout` / `command::stderr` references. ## 0.2.2 - 2026-07-28 diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index dfe3c71..3df8eef 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -2855,7 +2855,7 @@ def _command_output_summary(self, command: CommandRun, payload: dict[str, Any]) def read_output(self, args: dict[str, Any]) -> dict[str, Any]: output_ref = str(args.get("output_ref", "")) - match = re.fullmatch(r"command:([^:]+):(full|stdout|stderr)", output_ref) + match = re.fullmatch(r"command:([^:]+):(stdout|stderr)", output_ref) if not match: raise ToolFailure( "INVALID_ARGUMENT", @@ -2864,13 +2864,12 @@ def read_output(self, args: dict[str, Any]) -> dict[str, Any]: ) command = self._get_output_command(match.group(1)) command.refresh_status() - ref_stream = match.group(2) + stream = match.group(2) requested_stream = str(args.get("stream", "") or "") if requested_stream and requested_stream not in {"stdout", "stderr"}: raise ToolFailure("INVALID_ARGUMENT", "stream must be stdout or stderr.", category="validation") - if ref_stream in {"stdout", "stderr"} and requested_stream and requested_stream != ref_stream: + if requested_stream and requested_stream != stream: raise ToolFailure("INVALID_ARGUMENT", "stream does not match output_ref.", category="validation") - stream = ref_stream if ref_stream in {"stdout", "stderr"} else requested_stream or "stdout" data, retained_start_offset, total_stream_bytes, dropped_bytes = command.retained_stream_bytes(stream) requested_offset = max(0, int(args.get("offset", 0))) offset = max(requested_offset, retained_start_offset) @@ -2884,8 +2883,6 @@ def read_output(self, args: dict[str, Any]) -> dict[str, Any]: warnings.append(f"{stream} offset skipped dropped bytes") if dropped_bytes: warnings.append(f"older {stream} output was dropped from the rolling command buffer") - if ref_stream == "full": - warnings.append("legacy full output_ref defaults to stdout; use output_refs for stable stream paging") result = { "output_ref": output_ref, "stream_output_ref": f"command:{command.command_id}:{stream}", @@ -4602,6 +4599,7 @@ def input_schemas() -> dict[str, dict[str, Any]]: "command_id": {**string, "minLength": 1}, "signal": {**string, "enum": ["TERM", "KILL", "INT"], "default": "TERM"}, "wait_ms": {**integer, "minimum": 0, "maximum": 30000, "default": 5000}, + "kill_wait_ms": {**integer, "minimum": 0, "maximum": 30000, "default": 2000}, "max_output_bytes": {**integer, "minimum": 1, "maximum": 1048576, "default": 65536}, "verbosity": {**string, "enum": ["summary", "preview", "full"]}, "preview_bytes": {**integer, "minimum": 1, "maximum": 1048576, "default": 4096}, diff --git a/docs/runtime-contract-v0.2.md b/docs/runtime-contract-v0.2.md index fabbf9b..ac83f80 100644 --- a/docs/runtime-contract-v0.2.md +++ b/docs/runtime-contract-v0.2.md @@ -290,12 +290,15 @@ Input example: `{"command_id":"abc","chars":"yes\n"}`. ### kill_command -Inputs: `"command_id"`, `"signal"`, `"wait_ms"`, `"max_output_bytes"`, `"verbosity"`, `"preview_bytes"`. +Inputs: `"command_id"`, `"signal"`, `"wait_ms"`, `"kill_wait_ms"`, `"max_output_bytes"`, `"verbosity"`, `"preview_bytes"`. Annotations: `{"title":"Kill command","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}`. Statuses are `["terminated", "killed", "exited", "terminating", "not_found"]`. +If the process is still alive `"wait_ms"` after a non-KILL signal, the runtime +escalates to a hard kill and waits up to `"kill_wait_ms"` for the exit. + Example: `{"command_id":"abc","signal":"KILL"}`. ### read_output diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index 59c01a3..9bafbe7 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -350,8 +350,12 @@ def wait(self, timeout: float | None = None) -> None: runtime = Runtime(Path(tmp)) command = runtime._make_command(StillRunningProcess()) # type: ignore[arg-type] runtime.commands[command.command_id] = command + kill_args = {"command_id": command.command_id, "wait_ms": 0, "kill_wait_ms": 0} + # Guard against schema drift: these args must pass the same + # validation the MCP tools/call path applies. + server_module.validate_arguments("kill_command", kill_args) with patch.object(server_module, "terminate_process_group", return_value=None): - result = runtime.kill_command({"command_id": command.command_id, "wait_ms": 0, "kill_wait_ms": 0}) + result = runtime.kill_command(kill_args) self.assertFalse(result.get("killed"), result) self.assertEqual(result.get("status"), "terminating", result) @@ -1343,6 +1347,10 @@ def test_read_output_uses_absolute_stream_offsets_after_buffer_drop(self) -> Non self.assertEqual(page.get("omitted_bytes"), 2) self.assertEqual(page.get("retained_start_offset"), 2) + with self.assertRaises(ToolFailure) as rejected: + runtime.read_output({"output_ref": "command:manual-output:full"}) + self.assertEqual(rejected.exception.code, "INVALID_ARGUMENT") + command.stdout_cursor = 0 snapshot = command.snapshot_since_cursor(10) self.assertEqual(snapshot.get("stdout"), "cdef") From cd2e3011d2bca0e76df0c80cdc508d2710c9bc9a Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:00:08 +0000 Subject: [PATCH 03/25] Retain head output per stream and count retention evictions A tail-only rolling buffer loses the command echo and the first error lines first, which are usually the most diagnostic bytes of a large output. Each stream now freezes the earliest eighth of its buffer budget as a head segment next to the rolling tail; read_output serves both segments on stable absolute offsets and reports head_retained_bytes and evicted_gap_bytes for the evicted middle range. The workspace command manager now counts evictions past the head (evict_events, evicted_bytes_total) and reads that actually hit evicted bytes (read_output_omitted_hits, poll_omitted_hits), exposed through the server_info output_retention block, so operators can measure whether spilling retained output to disk is worth building. exec_command and read_output descriptions and docs now direct clients to redirect very large output to a file and page it with read_file or search_text instead of relying on retained output. Co-authored-by: Cursor --- CHANGELOG.md | 11 ++++ coding_tools_mcp/processes.py | 70 ++++++++++++++++++-- coding_tools_mcp/server.py | 83 ++++++++++++++++++++---- docs/runtime-contract-v0.2.md | 6 ++ docs/tools-and-schemas.md | 8 +++ tests/compliance/test_runtime_helpers.py | 68 +++++++++++++++++++ 6 files changed, 228 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6b34b..c9cd21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,17 @@ - `read_output` no longer accepts the undocumented `command::full` reference form, which silently read stdout only. Use the per-stream `command::stdout` / `command::stderr` references. +- Retained command output now keeps the earliest bytes per stream (a frozen + head segment, one eighth of the per-stream budget) in addition to the + rolling tail, so the command echo and first errors survive large outputs. + `read_output` reports `head_retained_bytes` and `evicted_gap_bytes`. +- `server_info` exposes an `output_retention` block with eviction counters + (`evict_events`, `evicted_bytes_total`) and omitted-read counters + (`read_output_omitted_hits`, `poll_omitted_hits`) so operators can measure + how often clients hit evicted output. +- `exec_command` and `read_output` tool descriptions now direct clients to + redirect very large output to a file and page it with `read_file` / + `search_text`. ## 0.2.2 - 2026-07-28 diff --git a/coding_tools_mcp/processes.py b/coding_tools_mcp/processes.py index 382f019..6302b9e 100644 --- a/coding_tools_mcp/processes.py +++ b/coding_tools_mcp/processes.py @@ -13,6 +13,11 @@ COMMAND_BUFFER_BYTES = 524_288 +# Fraction of the per-stream budget frozen as the head segment. The head keeps +# the earliest output (command echo, first error) that a tail-only rolling +# buffer would lose first, mirroring the head+tail retention used by other +# agent runtimes. +COMMAND_HEAD_BUFFER_DIVISOR = 8 HARD_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) @@ -123,6 +128,8 @@ class CommandRun: warnings: list[str] = field(default_factory=list) stdout: bytearray = field(default_factory=bytearray) stderr: bytearray = field(default_factory=bytearray) + stdout_head: bytearray = field(default_factory=bytearray) + stderr_head: bytearray = field(default_factory=bytearray) stdout_start_offset: int = 0 stderr_start_offset: int = 0 stdout_cursor: int = 0 @@ -142,34 +149,62 @@ class CommandRun: timed_out: bool = False terminating: bool = False pty_master_fd: int | None = None + on_evict: Any = None _stdin_closed: bool = False + @property + def head_buffer_limit(self) -> int: + return self.buffer_limit // COMMAND_HEAD_BUFFER_DIVISOR + @property def retained_bytes(self) -> int: with self.lock: - return len(self.stdout) + len(self.stderr) + stdout_head_unique = min(len(self.stdout_head), self.stdout_start_offset) + stderr_head_unique = min(len(self.stderr_head), self.stderr_start_offset) + return len(self.stdout) + len(self.stderr) + stdout_head_unique + stderr_head_unique def append_stdout(self, chunk: bytes) -> None: with self.lock: + head_capacity = self.head_buffer_limit - len(self.stdout_head) + if head_capacity > 0: + self.stdout_head.extend(chunk[:head_capacity]) self.stdout.extend(chunk) self.stdout_total_bytes += len(chunk) - self.stdout_dropped_bytes += _trim_buffer( + previous_start = self.stdout_start_offset + dropped = _trim_buffer( self.stdout, total_bytes=self.stdout_total_bytes, start_offset_attr="stdout_start_offset", command=self, ) + self.stdout_dropped_bytes += dropped + if dropped: + self._report_eviction("stdout", previous_start, self.stdout_start_offset, len(self.stdout_head)) def append_stderr(self, chunk: bytes) -> None: with self.lock: + head_capacity = self.head_buffer_limit - len(self.stderr_head) + if head_capacity > 0: + self.stderr_head.extend(chunk[:head_capacity]) self.stderr.extend(chunk) self.stderr_total_bytes += len(chunk) - self.stderr_dropped_bytes += _trim_buffer( + previous_start = self.stderr_start_offset + dropped = _trim_buffer( self.stderr, total_bytes=self.stderr_total_bytes, start_offset_attr="stderr_start_offset", command=self, ) + self.stderr_dropped_bytes += dropped + if dropped: + self._report_eviction("stderr", previous_start, self.stderr_start_offset, len(self.stderr_head)) + + def _report_eviction(self, stream: str, previous_start: int, new_start: int, head_len: int) -> None: + if self.on_evict is None: + return + lost_bytes = max(0, new_start - max(previous_start, head_len)) + if lost_bytes: + self.on_evict(stream, lost_bytes) def write_input(self, data: bytes) -> None: if self._stdin_closed: @@ -296,12 +331,32 @@ def retained_output_bytes(self) -> bytes: sections.extend([b"--- stderr ---\n", stderr]) return b"".join(sections) - def retained_stream_bytes(self, stream: str) -> tuple[bytes, int, int, int]: + def retained_stream_segments(self, stream: str) -> tuple[bytes, bytes, int, int, int]: + """Return (head, tail, tail_start_offset, total_bytes, tail_dropped_bytes). + + The retained set for a stream is the frozen head segment covering + absolute offsets [0, len(head)) plus the rolling tail window covering + [tail_start_offset, total_bytes). While the tail has not dropped + anything the head is a duplicate prefix of the tail; once the tail + rolls past the head, offsets between the two segments are evicted. + """ with self.lock: if stream == "stdout": - return bytes(self.stdout), self.stdout_start_offset, self.stdout_total_bytes, self.stdout_dropped_bytes + return ( + bytes(self.stdout_head), + bytes(self.stdout), + self.stdout_start_offset, + self.stdout_total_bytes, + self.stdout_dropped_bytes, + ) if stream == "stderr": - return bytes(self.stderr), self.stderr_start_offset, self.stderr_total_bytes, self.stderr_dropped_bytes + return ( + bytes(self.stderr_head), + bytes(self.stderr), + self.stderr_start_offset, + self.stderr_total_bytes, + self.stderr_dropped_bytes, + ) raise ValueError(f"Unknown output stream: {stream}") @@ -386,7 +441,8 @@ def _trim_buffer( start_offset_attr: str, command: CommandRun, ) -> int: - overflow = len(buffer) - command.buffer_limit + tail_limit = command.buffer_limit - command.head_buffer_limit + overflow = len(buffer) - tail_limit if overflow <= 0: return 0 del buffer[:overflow] diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 3df8eef..94b2fd6 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -60,6 +60,7 @@ from .processes import ( HARD_KILL_SIGNAL, COMMAND_BUFFER_BYTES, + COMMAND_HEAD_BUFFER_DIVISOR, CommandRun, spawn_process, start_reader_threads, @@ -618,7 +619,9 @@ def _image_content(payload: dict[str, Any]) -> list[dict[str, Any]]: description=( "Run a bounded command under runtime policy. Pass workdir explicitly for reconnect-safe paths. " "A still-running command returns command_id. Example: " - "{\"cmd\":\"pytest -q\",\"workdir\":\".\",\"yield_time_ms\":30000}." + "{\"cmd\":\"pytest -q\",\"workdir\":\".\",\"yield_time_ms\":30000}. " + "Retained output is bounded per stream; for very large output redirect to a file " + "(cmd > out.log 2>&1) and page it with read_file or search_text." ), destructive=True, open_world=True, @@ -643,6 +646,8 @@ def _image_content(payload: dict[str, Any]) -> list[dict[str, Any]]: title="Read output", description=( "Read retained command output using an output_ref returned by exec_command/write_stdin. " + "Each stream retains the earliest output (head) plus the most recent output (rolling tail); " + "bytes between them may be evicted and are reported via evicted_gap_bytes. " "Example: {\"output_ref\":\"command:abc:stdout\",\"offset\":0,\"limit\":4096}." ), read_only=True, @@ -1235,6 +1240,31 @@ def __init__(self, workspace: Path) -> None: self.lock = threading.Lock() self.starting_commands = 0 self.closed = False + # Retention observability: how often output is evicted past the head + # segment and how often clients actually ask for evicted bytes. High + # hit rates are the signal to consider spilling output to disk. + self._retention_stats_lock = threading.Lock() + self._retention_stats = { + "evict_events": 0, + "evicted_bytes_total": 0, + "read_output_omitted_hits": 0, + "poll_omitted_hits": 0, + } + + def record_output_eviction(self, stream: str, lost_bytes: int) -> None: + with self._retention_stats_lock: + self._retention_stats["evict_events"] += 1 + self._retention_stats["evicted_bytes_total"] += lost_bytes + + def record_omitted_read(self, kind: str) -> None: + key = f"{kind}_omitted_hits" + with self._retention_stats_lock: + if key in self._retention_stats: + self._retention_stats[key] += 1 + + def retention_stats_snapshot(self) -> dict[str, int]: + with self._retention_stats_lock: + return dict(self._retention_stats) def close(self) -> None: with self.lock: @@ -1521,6 +1551,11 @@ def server_info_payload(self) -> dict[str, Any]: "shell_env_inherit": self.shell_env_policy.inherit, "shell_env_include_only": list(self.shell_env_policy.include_only), "shell_env_exclude": list(self.shell_env_policy.exclude), + "output_retention": { + "buffer_bytes_per_stream": COMMAND_BUFFER_BYTES, + "head_bytes_per_stream": COMMAND_BUFFER_BYTES // COMMAND_HEAD_BUFFER_DIVISOR, + **self.command_manager.retention_stats_snapshot(), + }, "endpoint_path": MCP_ENDPOINT_PATH, "project_context": { "root_instruction_files": [item.path for item in self.project_context.root_files], @@ -2680,6 +2715,7 @@ def _make_command( timeout_at=timeout_at, warnings=warnings or [], pty_master_fd=pty_master_fd, + on_evict=self.command_manager.record_output_eviction, ) def _remember_output_command(self, command: CommandRun) -> None: @@ -2755,12 +2791,17 @@ def _format_command_output(self, command: CommandRun, payload: dict[str, Any], a "stderr": f"command:{command.command_id}:stderr", } truncated_streams: list[str] = [] + cursor_skipped_drop = False for stream in ("stdout", "stderr"): omitted = payload.get(f"{stream}_omitted_bytes") + if isinstance(omitted, int) and omitted > 0: + cursor_skipped_drop = True if payload.get(f"{stream}_truncated") or ( isinstance(omitted, int) and omitted > 0 ): truncated_streams.append(stream) + if cursor_skipped_drop: + self.command_manager.record_omitted_read("poll") output_stream = ( truncated_streams[0] if truncated_streams @@ -2830,7 +2871,7 @@ def _format_command_output(self, command: CommandRun, payload: dict[str, Any], a preview_streams = [ stream for stream in ("stdout", "stderr") - if command.retained_stream_bytes(stream)[2] > 0 + if command.retained_stream_segments(stream)[3] > 0 ] compact["truncated_output_streams"] = preview_streams preview_actions = [read_output_action(output_refs[stream]) for stream in preview_streams] @@ -2870,19 +2911,37 @@ def read_output(self, args: dict[str, Any]) -> dict[str, Any]: raise ToolFailure("INVALID_ARGUMENT", "stream must be stdout or stderr.", category="validation") if requested_stream and requested_stream != stream: raise ToolFailure("INVALID_ARGUMENT", "stream does not match output_ref.", category="validation") - data, retained_start_offset, total_stream_bytes, dropped_bytes = command.retained_stream_bytes(stream) + head, tail, tail_start_offset, total_stream_bytes, dropped_bytes = command.retained_stream_segments(stream) requested_offset = max(0, int(args.get("offset", 0))) - offset = max(requested_offset, retained_start_offset) limit = max(1, min(int(args.get("limit", EXEC_PREVIEW_BYTES)), COMMAND_BUFFER_BYTES)) - buffer_offset = max(0, offset - retained_start_offset) - chunk = data[buffer_offset : buffer_offset + limit] + head_len = len(head) + evicted_gap_bytes = max(0, tail_start_offset - head_len) + # The retained set is the frozen head [0, head_len) plus the rolling + # tail [tail_start_offset, total). Serve from whichever segment holds + # the requested offset; offsets inside the evicted gap clamp forward + # to the tail. Chunks never span the gap so offsets stay stable. + if requested_offset >= tail_start_offset: + offset = requested_offset + buffer_offset = offset - tail_start_offset + chunk = tail[buffer_offset : buffer_offset + limit] + elif requested_offset < head_len: + offset = requested_offset + chunk = head[offset : min(head_len, offset + limit)] + else: + offset = tail_start_offset + chunk = tail[:limit] next_offset = offset + len(chunk) if offset + len(chunk) < total_stream_bytes else None - omitted_bytes = max(0, retained_start_offset - requested_offset) + omitted_bytes = offset - requested_offset warnings: list[str] = [] if omitted_bytes: warnings.append(f"{stream} offset skipped dropped bytes") - if dropped_bytes: - warnings.append(f"older {stream} output was dropped from the rolling command buffer") + if evicted_gap_bytes: + warnings.append( + f"{stream} output between the retained head and the rolling tail was evicted; " + "redirect large output to a file (cmd > out.log 2>&1) to keep everything" + ) + if omitted_bytes: + self.command_manager.record_omitted_read("read_output") result = { "output_ref": output_ref, "stream_output_ref": f"command:{command.command_id}:{stream}", @@ -2892,8 +2951,10 @@ def read_output(self, args: dict[str, Any]) -> dict[str, Any]: "limit": limit, "content": chunk.decode("utf-8", errors="replace"), "next_offset": next_offset, - "total_retained_bytes": len(data), - "retained_start_offset": retained_start_offset, + "total_retained_bytes": len(tail) + min(head_len, tail_start_offset), + "head_retained_bytes": head_len, + "evicted_gap_bytes": evicted_gap_bytes, + "retained_start_offset": tail_start_offset, "total_stream_bytes": total_stream_bytes, "stdout_dropped_bytes": command.stdout_dropped_bytes, "stderr_dropped_bytes": command.stderr_dropped_bytes, diff --git a/docs/runtime-contract-v0.2.md b/docs/runtime-contract-v0.2.md index ac83f80..c6c6dbc 100644 --- a/docs/runtime-contract-v0.2.md +++ b/docs/runtime-contract-v0.2.md @@ -307,6 +307,12 @@ Inputs: `"output_ref"`, `"stream"`, `"offset"`, `"limit"`. Annotations: `{"title":"Read output","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. +Retention is head+tail per stream: the earliest bytes (head) and the most +recent bytes (rolling tail) are kept; the range between them may be evicted +once the per-stream buffer overflows. Responses report `head_retained_bytes`, +`evicted_gap_bytes`, and `omitted_bytes`; reads inside the evicted range clamp +forward to the tail. Offsets remain absolute and stable. + Example: `{"output_ref":"command:abc:stdout","offset":0,"limit":4096}`. ### git_status diff --git a/docs/tools-and-schemas.md b/docs/tools-and-schemas.md index 2849e76..12bf558 100644 --- a/docs/tools-and-schemas.md +++ b/docs/tools-and-schemas.md @@ -129,6 +129,14 @@ are stream-specific absolute byte positions. Runtime limits bound active commands, retained completed commands, per-command output, total output, and retention time. +Each stream retains the earliest output (a frozen head segment, one eighth of +the per-stream budget) plus the most recent output (a rolling tail). When a +command produces more output than the budget, bytes between the head and the +tail are evicted permanently; `read_output` reports the loss via +`evicted_gap_bytes` and `omitted_bytes`. For output expected to exceed the +budget, redirect it to a file (`cmd > out.log 2>&1`) and page it with +`read_file` or `search_text` instead of relying on retained output. + Use `tty: true` only when a program requires a terminal. POSIX receives a real PTY (`isatty()` is true). This build returns `TTY_UNSUPPORTED` on Windows rather than labeling pipes as a TTY. diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index 9bafbe7..dad6bc5 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -1357,6 +1357,74 @@ def test_read_output_uses_absolute_stream_offsets_after_buffer_drop(self) -> Non self.assertEqual(snapshot.get("stdout_omitted_bytes"), 2) self.assertIs(snapshot.get("truncated"), True) + def test_read_output_serves_retained_head_before_evicted_gap(self) -> None: + data = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!?" + self.assertEqual(len(data), 64) + with TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode="trusted") + with subprocess.Popen([sys.executable, "-c", ""], stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process: + # buffer_limit 32 keeps a 4-byte frozen head and a 28-byte tail. + command = server_module.CommandRun(command_id="head-tail", process=process, buffer_limit=32) + command.append_stdout(data) + runtime._remember_output_command(command) + + first = runtime.read_output({"output_ref": "command:head-tail:stdout", "offset": 0, "limit": 10}) + self.assertEqual(first.get("content"), "abcd") + self.assertEqual(first.get("offset"), 0) + self.assertEqual(first.get("next_offset"), 4) + self.assertEqual(first.get("head_retained_bytes"), 4) + self.assertEqual(first.get("evicted_gap_bytes"), 32) + self.assertEqual(first.get("omitted_bytes"), 0) + self.assertEqual(first.get("retained_start_offset"), 36) + self.assertEqual(first.get("total_retained_bytes"), 32) + + second = runtime.read_output({"output_ref": "command:head-tail:stdout", "offset": 4, "limit": 10}) + self.assertEqual(second.get("offset"), 36) + self.assertEqual(second.get("omitted_bytes"), 32) + self.assertEqual(second.get("content"), data[36:46].decode()) + self.assertEqual(second.get("next_offset"), 46) + self.assertTrue(any("skipped dropped" in warning for warning in second.get("warnings", []))) + + third = runtime.read_output({"output_ref": "command:head-tail:stdout", "offset": 60, "limit": 10}) + self.assertEqual(third.get("content"), data[60:].decode()) + self.assertIsNone(third.get("next_offset")) + + def test_output_retention_counters_and_server_info_track_evicted_output(self) -> None: + data = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!?" + with TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode="trusted") + with subprocess.Popen([sys.executable, "-c", ""], stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process: + command = server_module.CommandRun( + command_id="evicted", + process=process, + buffer_limit=32, + on_evict=runtime.command_manager.record_output_eviction, + ) + command.append_stdout(data) + runtime._remember_output_command(command) + + stats = runtime.command_manager.retention_stats_snapshot() + self.assertEqual(stats["evict_events"], 1) + self.assertEqual(stats["evicted_bytes_total"], 32) + self.assertEqual(stats["read_output_omitted_hits"], 0) + + runtime.read_output({"output_ref": "command:evicted:stdout", "offset": 8, "limit": 10}) + stats = runtime.command_manager.retention_stats_snapshot() + self.assertEqual(stats["read_output_omitted_hits"], 1) + + retention = runtime.server_info_payload()["output_retention"] + self.assertEqual(retention["evict_events"], 1) + self.assertEqual(retention["evicted_bytes_total"], 32) + self.assertEqual(retention["read_output_omitted_hits"], 1) + self.assertEqual( + retention["buffer_bytes_per_stream"], + server_module.COMMAND_BUFFER_BYTES, + ) + self.assertEqual( + retention["head_bytes_per_stream"], + server_module.COMMAND_BUFFER_BYTES // 8, + ) + def test_default_cwd_and_git_convenience_tools(self) -> None: if server_module.shutil.which("git") is None: self.skipTest("git is not available") From 784404c4492461d2ed37be8b038c328684dac8e2 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:46:45 +0000 Subject: [PATCH 04/25] Gate PowerShell dynamic syntax instead of scanning for cmdlet names PowerShell resolves commands at runtime, so the new cmdlet-name scans could not see what a command would actually run. Both of these passed every scan while downloading a file and deleting a tree recursively: $c='Invoke-WebRequest'; & $c example.com $p=@{Recurse=$true}; Remove-Item . @p Enumerating more cmdlet names cannot fix this, because variables, splatting, redefined aliases, .NET member access, and base64 script payloads all express the same commands without naming them. Safe mode now requires the existing shell_expansion permission for PowerShell dynamic syntax and the inline_script permission for nested shells, which restores the precondition the destructive and network scans depend on: the command text is literal. The gate only applies where PowerShell is the interpreter, so POSIX expansion policy is unchanged, and it runs after the existing scans so recognized commands keep their precise permission label. Literal PowerShell keeps working in safe mode; dynamic syntax needs request_permissions or trusted mode, which docs now state. Also anchors the Net. network pattern on a word boundary so ordinary words ending in "net." no longer register as network access. Co-authored-by: Cursor --- README.md | 6 +++ coding_tools_mcp/server.py | 66 +++++++++++++++++++++++++++++- docs/limitations.md | 9 ++++ tests/test_windows_pwsh.py | 84 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a35062..f634d63 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ On Windows, string commands run through PowerShell 7 (`pwsh`) with the launcher. The server reports `SHELL_NOT_FOUND` or `SHELL_VERSION_UNSUPPORTED` instead of falling back to `cmd.exe`. +Because PowerShell resolves commands at runtime, `safe` mode on Windows gates +dynamic syntax — variables, splatting, call and dot-source operators, `::` +member access, alias/expression evaluation, and nested shells — behind the +`shell_expansion` and `inline_script` permissions. Literal commands are +unaffected; use `request_permissions` or `trusted` mode for the rest. + ## Seven things to try **1. Make Claude Desktop your coding agent.** The config above is all it diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index aab0242..e774c21 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -182,7 +182,7 @@ class ModeCapabilities: POWERSHELL_NETWORK_RE = re.compile( r"(?:^|[;&|{}\r\n])\s*(?:Invoke-WebRequest|Invoke-RestMethod|Start-BitsTransfer|" r"Test-NetConnection|Test-Connection|Resolve-DnsName|iwr|irm|tnc|ping(?:\.exe)?|" - r"nslookup(?:\.exe)?|tracert(?:\.exe)?)\b|(?:System\.)?Net\.", + r"nslookup(?:\.exe)?|tracert(?:\.exe)?)\b|\b(?:System\.)?Net\.", re.I, ) SHELL_EXPANSION_RE = re.compile(r"(`|\$\(|\$\{)") @@ -195,6 +195,20 @@ class ModeCapabilities: r"(?=[^;&|{}\r\n]*\s-(?:r|re|rec|recu|recur|recurs|recurse)\b)", re.I, ) +# PowerShell resolves commands at runtime, so scanning for cmdlet names cannot +# see through a variable, a splatted parameter set, a redefined alias, or a +# .NET member call. Any of those constructs makes the destructive and network +# scans above unsound, so they require the same explicit permission that POSIX +# command substitution already requires instead of being scanned for keywords. +POWERSHELL_DYNAMIC_RE = re.compile( + r"(?P\$)" + r"|(?P(?:^|[\s;&|(){},=])@)" + r"|(?P::)" + r"|(?P(?:^|[;|(){}\r\n]|&&|\|\|)\s*(?:&(?!&)|\.)\s)" + r"|(?P\b(?:Invoke-Expression|iex|Invoke-Command|icm|New-Object|" + r"Add-Type|Set-Alias|New-Alias|sal|nal)\b)", + re.I, +) MAX_HTTP_REQUEST_BYTES = 1_048_576 EXEC_PREVIEW_BYTES = 4096 MAX_ACTIVE_COMMANDS = 16 @@ -2531,6 +2545,24 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: category="permission", details={"permission": "network", "command": compact}, ) + # Runs last so a command the scans above already recognized keeps its + # precise permission label; this is the catch-all for the PowerShell + # syntax that makes those scans unsound in the first place. + if not self.capabilities.shell_expansion and powershell_executes_string_commands(): + construct = powershell_dynamic_construct(cmd) + if construct is not None: + raise ToolFailure( + "PERMISSION_REQUIRED", + "PowerShell dynamic syntax requires explicit permission because the command " + "a variable, splat, alias, or .NET member resolves to cannot be verified " + "statically.", + category="permission", + details={ + "permission": "shell_expansion", + "construct": construct, + "command": compact, + }, + ) def _add_exec_diagnostics(self, payload: dict[str, Any]) -> None: diagnostics = exec_output_diagnostics(payload) @@ -3764,6 +3796,20 @@ def inline_script_segment(command: str | None, args: list[str]) -> dict[str, str return {"command": name, "option": option} if name in {"ruby", "perl"} and "-e" in args: return {"command": name, "option": "-e"} + if name in {"pwsh", "pwsh.exe", "powershell", "powershell.exe"}: + for arg in args: + if not arg.startswith("-"): + continue + option = arg.lstrip("-").lower() + # PowerShell accepts any unambiguous prefix, so -e, -enc, and + # -EncodedCommand all smuggle a base64 script past text scanning. + if option and ("command".startswith(option) or "encodedcommand".startswith(option)): + return {"command": name, "option": arg} + return None + if name in {"cmd", "cmd.exe"}: + for arg in args: + if arg.lstrip("-/").lower() in {"c", "k"}: + return {"command": name, "option": arg} return None @@ -3918,6 +3964,24 @@ def is_inspectable_path_argument(token: str) -> bool: return "." in PurePosixPath(normalized).name +def powershell_executes_string_commands() -> bool: + """True when this host runs string commands through PowerShell 7. + + Mirrors the spawn decision in processes.spawn_process, which wraps Windows + string commands in pwsh. Command policy has to agree with that decision: + PowerShell syntax is only worth gating where PowerShell is the interpreter. + """ + + return os.name == "nt" + + +def powershell_dynamic_construct(command: str) -> str | None: + match = POWERSHELL_DYNAMIC_RE.search(command) + if match is None: + return None + return match.lastgroup + + def is_literal_network_reference_command(command: str) -> bool: try: tokens = shlex_split(command) diff --git a/docs/limitations.md b/docs/limitations.md index c391d22..58b420e 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -11,6 +11,15 @@ executable with `CODING_TOOLS_MCP_PWSH_PATH`; otherwise the server searches absolute entries on its own process `PATH` while excluding the current directory tree. There is no `cmd.exe` fallback. +- Windows `safe` mode cannot statically decide what PowerShell dynamic syntax + resolves to, so variables (`$`), splatting (`@`), the call and dot-source + operators, .NET member access (`::`), and alias or expression evaluation + cmdlets require the `shell_expansion` permission even when the command would + turn out to be harmless. Nested shells (`pwsh -Command`, `pwsh + -EncodedCommand`, `cmd /c`) require `inline_script`. Use + `request_permissions` or `trusted` mode for commands that need them. Command + scanning is not a sandbox: this build has no OS-level confinement on Windows, + so `safe` mode there is a best-effort gate rather than a boundary. - Portable filesystems do not provide a transaction across unrelated directories. `apply_patch` keeps same-directory backups and rolls back the full staged set, but a storage failure that also prevents rollback is surfaced diff --git a/tests/test_windows_pwsh.py b/tests/test_windows_pwsh.py index 1cc33ae..732e96a 100644 --- a/tests/test_windows_pwsh.py +++ b/tests/test_windows_pwsh.py @@ -7,6 +7,7 @@ from unittest.mock import patch from coding_tools_mcp import processes +from coding_tools_mcp import server from coding_tools_mcp.errors import ToolFailure from coding_tools_mcp.server import Runtime, ShellEnvPolicy @@ -207,5 +208,88 @@ def test_safe_mode_blocks_recursive_powershell_deletion_aliases_and_abbreviation runtime.close() +class WindowsPowerShellDynamicSyntaxTests(unittest.TestCase): + """PowerShell resolves commands at runtime, so cmdlet-name scanning alone + cannot decide whether a command is destructive or reaches the network.""" + + def assert_policy(self, command: str, *, mode: str = "safe") -> ToolFailure | None: + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode=mode) + try: + with patch.object(server, "powershell_executes_string_commands", return_value=True): + try: + runtime._check_command_policy(command, {}) + except ToolFailure as failure: + return failure + finally: + runtime.close() + return None + + def test_safe_mode_gates_command_names_built_from_variables_and_splatting(self) -> None: + # Both of these pass every cmdlet-name scan while still invoking a + # network download and a recursive delete. + cases = { + "$c='Invoke-WebRequest'; & $c example.com": "expansion", + "$p=@{Recurse=$true}; Remove-Item . @p": "expansion", + "Set-Alias grab Invoke-WebRequest; grab example.com": "dynamic_eval", + "[IO.File]::Delete('C:\\data\\report.csv')": "static_member", + "Remove-Item . @args": "splatting", + ". .\\payload.ps1": "call_operator", + "iex (Get-Content payload.txt -Raw)": "dynamic_eval", + } + for command, construct in cases.items(): + with self.subTest(command=command): + failure = self.assert_policy(command) + self.assertIsNotNone(failure, f"{command!r} was allowed in safe mode") + assert failure is not None + self.assertEqual(failure.details.get("permission"), "shell_expansion") + self.assertEqual(failure.details.get("construct"), construct) + + def test_safe_mode_gates_nested_shells_that_smuggle_encoded_scripts(self) -> None: + for command in ( + "pwsh -EncodedCommand SQBuAHYAbwBrAGUA", + "pwsh -enc SQBuAHYAbwBrAGUA", + "pwsh -Command Invoke-WebRequest example.com", + "powershell.exe -c whoami", + "cmd /c del /s /q C:\\data", + ): + with self.subTest(command=command): + failure = self.assert_policy(command) + self.assertIsNotNone(failure, f"{command!r} was allowed in safe mode") + assert failure is not None + self.assertEqual(failure.details.get("permission"), "inline_script") + + def test_safe_mode_still_allows_literal_powershell_commands(self) -> None: + for command in ( + "Get-ChildItem -Path src -Recurse -Name", + "Write-Output ok", + "git commit -m 'fix parser' && git log --oneline -1", + "git config user.email dev@example.com", + ".\\build.exe --release", + "Get-Content README.md | Select-String planet.txt", + ): + with self.subTest(command=command): + self.assertIsNone(self.assert_policy(command), f"{command!r} was blocked in safe mode") + + def test_trusted_mode_allows_dynamic_syntax(self) -> None: + for command in ("$c='Get-Date'; & $c", "Remove-Item . @p"): + with self.subTest(command=command): + self.assertIsNone(self.assert_policy(command, mode="trusted")) + + def test_network_scan_does_not_flag_words_ending_in_net(self) -> None: + self.assertIsNone(server.POWERSHELL_NETWORK_RE.search("Get-Content planet.txt")) + self.assertIsNotNone(server.POWERSHELL_NETWORK_RE.search("New-Object System.Net.WebClient")) + self.assertIsNotNone(server.POWERSHELL_NETWORK_RE.search("[Net.Dns]::GetHostAddresses('a')")) + + def test_posix_hosts_keep_their_existing_expansion_policy(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode="safe") + try: + with patch.object(server, "powershell_executes_string_commands", return_value=False): + runtime._check_command_policy("echo $HOME", {}) + finally: + runtime.close() + + if __name__ == "__main__": unittest.main() From ffe69b3311a3925f810e1beea7f682420bed1abb Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:11:24 +0000 Subject: [PATCH 05/25] Run compliance CI on release and hotfix branches Co-authored-by: Cursor --- .github/workflows/compliance.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index faeb9a2..8fd2eb5 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -2,9 +2,9 @@ name: compliance on: push: - branches: [main, "recover-*"] + branches: [main, "recover-*", "release/**", "hotfix/**"] pull_request: - branches: [main] + branches: [main, "release/**"] workflow_dispatch: workflow_call: From b79a9d4accd300e56fcb9df361ee23a850e26f54 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:19:48 +0000 Subject: [PATCH 06/25] Replay duplicate initialize on one persistent STDIO session A connector that probes for a newer protocol, falls back to the legacy handshake, and then re-sends initialize on the same STDIO process was answered with -32600 Server is already initialized, which failed its tool scan even though the session was healthy (issue #39). A repeat initialize that negotiates the same version now replays the handshake result instead. The replay goes through a new Runtime.initialize_result that builds the payload without running the initializer, so no session state is reset and the telemetry session count stays tied to real sessions. A repeat that asks for a different protocol version is still rejected. The regression test drives the reported sequence over stdio: an unsupported server/discover probe, initialize with id 1, initialize with id 0, then notifications/initialized and tools/list. It asserts only what the client depends on -- the probe is answered on a live process and both handshakes return the same result -- so the probe's error code stays free to change. Co-authored-by: Cursor --- CHANGELOG.md | 10 +++ coding_tools_mcp/protocol.py | 25 ++++-- coding_tools_mcp/server.py | 9 +++ tests/compliance/test_mcp_contract.py | 110 +++++++++++++++----------- 4 files changed, 102 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9cd21e..7df3329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,16 @@ redirect very large output to a file and page it with `read_file` / `search_text`. +### Fixed + +- A second `initialize` on one persistent STDIO session now replays the + negotiated handshake result instead of failing with `-32600 Server is already + initialized`. Connectors that probe for a newer protocol, fall back to the + legacy handshake, and then re-send `initialize` on the same process could not + finish a tool scan at all. The replay reuses the existing session, so no + session state is reset and no extra telemetry session is recorded; a repeat + that asks for a different protocol version is still rejected. + ## 0.2.2 - 2026-07-28 ### Fixed diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index 2b5baa4..2a7e3b4 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -93,13 +93,26 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None if not runtime.initialized and method not in {"initialize", "ping"}: raise JsonRpcError(-32002, "Server not initialized") if method == "initialize": - if runtime.initialized: - raise JsonRpcError(-32600, "Server is already initialized") validate_initialize_request(request) - runtime.protocol_version = validate_initialize_params(params) - client_info = params.get("clientInfo") - result = runtime.initialize(client_info if isinstance(client_info, dict) else None) - runtime.initialized = True + negotiated_version = validate_initialize_params(params) + if runtime.initialized: + # Some connectors send a second initialize on one persistent + # STDIO process. Rejecting it fails their tool scan even though + # the session is healthy, so replay the negotiated handshake + # instead. The initializer is not run again, so no session + # state is reset by a repeat. + if negotiated_version != runtime.protocol_version: + raise JsonRpcError( + -32600, + "Server is already initialized with a different protocol version", + {"expected": runtime.protocol_version, "received": negotiated_version}, + ) + result = runtime.initialize_result() + else: + runtime.protocol_version = negotiated_version + client_info = params.get("clientInfo") + result = runtime.initialize(client_info if isinstance(client_info, dict) else None) + runtime.initialized = True elif method == "notifications/initialized": return None elif method == "notifications/cancelled": diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 94b2fd6..1bcddd9 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -1471,6 +1471,15 @@ def is_allowed_command_tmp_path(self, candidate: str) -> bool: def initialize(self, client_info: dict[str, Any] | None = None) -> dict[str, Any]: self.telemetry.record_session_start(client_info, self.protocol_version) + return self.initialize_result() + + def initialize_result(self) -> dict[str, Any]: + """Build the handshake payload without recording a new session. + + Replaying a duplicate initialize uses this so the telemetry session + count stays tied to real sessions. + """ + return { "protocolVersion": self.protocol_version, "capabilities": {"tools": {"listChanged": False}}, diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index e3a3bf3..835f6b9 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -944,23 +944,7 @@ def test_initialize_rejects_older_client_protocol(self) -> None: self.assertEqual(response.get("error", {}).get("code"), -32602) def test_stdio_transport_uses_newline_delimited_json_rpc_only(self) -> None: - process = subprocess.Popen( - [ - sys.executable, - "-m", - "coding_tools_mcp", - "--workspace", - str(self.workspace.root), - "--stdio", - ], - cwd=str(self.workspace.root), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=self.server_process_env(), - text=True, - start_new_session=True, - ) + process = self.start_stdio_server() try: self.assertIsNotNone(process.stdin) process.stdin.write("{not-json}\n") @@ -1001,37 +985,10 @@ def test_stdio_transport_uses_newline_delimited_json_rpc_only(self) -> None: self.assertIsInstance(tools, list) self.assertTrue({tool.get("name") for tool in tools} >= set(REQUIRED_TOOLS)) finally: - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - pass - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) - process.wait(timeout=2) - for stream in (process.stdin, process.stdout, process.stderr): - if stream is not None: - stream.close() + self.stop_process(process) def test_stdio_rejects_preinitialize_calls_and_accepts_cancel_notification(self) -> None: - process = subprocess.Popen( - [ - sys.executable, - "-m", - "coding_tools_mcp", - "--workspace", - str(self.workspace.root), - "--stdio", - ], - cwd=str(self.workspace.root), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=self.server_process_env(), - text=True, - start_new_session=True, - ) + process = self.start_stdio_server() try: rejected = self.stdio_rpc_allow_error( process, @@ -1058,6 +1015,48 @@ def test_stdio_rejects_preinitialize_calls_and_accepts_cancel_notification(self) finally: self.stop_process(process) + def test_stdio_replays_duplicate_initialize_after_a_failed_probe(self) -> None: + """Replay the sequence from issue #39: probe, initialize, initialize again.""" + + process = self.start_stdio_server() + try: + probe = self.stdio_rpc_allow_error( + process, + {"jsonrpc": "2.0", "id": "openai-mcp-discover", "method": "server/discover", "params": {}}, + ) + # The probe's error code is free to change as new methods land; what + # the client depends on is an answered request on a live process. + self.assertIn("error", probe) + self.assertEqual(probe.get("id"), "openai-mcp-discover") + self.assertIsNone(process.poll(), "an unsupported probe must not end the stdio session") + + params = { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "duplicate-initialize-client", "version": "1.0"}, + } + first = self.stdio_rpc( + process, + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params}, + ) + self.assertEqual(first.get("result", {}).get("protocolVersion"), "2025-11-25") + + replayed = self.stdio_rpc( + process, + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": params}, + ) + self.assertEqual(replayed.get("result"), first.get("result")) + + self.stdio_send(process, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + self.assert_no_stdio_response(process) + + listed = self.stdio_rpc(process, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + tools = listed.get("result", {}).get("tools") + self.assertIsInstance(tools, list) + self.assertTrue({tool.get("name") for tool in tools} >= set(REQUIRED_TOOLS)) + finally: + self.stop_process(process) + def assert_content_text_is_agent_readable(self, result: dict[str, Any]) -> str: structured = result.get("structuredContent") self.assertIsInstance(structured, dict, f"structuredContent must be an object: {result!r}") @@ -1067,6 +1066,25 @@ def assert_content_text_is_agent_readable(self, result: dict[str, Any]) -> str: self.assertTrue(text_items, f"content must include agent-readable text: {result!r}") return "\n".join(str(item) for item in text_items) + def start_stdio_server(self) -> subprocess.Popen[str]: + return subprocess.Popen( + [ + sys.executable, + "-m", + "coding_tools_mcp", + "--workspace", + str(self.workspace.root), + "--stdio", + ], + cwd=str(self.workspace.root), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self.server_process_env(), + text=True, + start_new_session=True, + ) + def stdio_send(self, process: subprocess.Popen[str], payload: dict[str, Any]) -> None: self.assertIsNotNone(process.stdin) line = json.dumps(payload, separators=(",", ":")) From db2f655d3da7adecac24cf6f607c17d7fbd43bac Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:23:45 +0000 Subject: [PATCH 07/25] Return -32601 for unknown methods before the handshake A client probing for a method this server does not implement was answered with -32002 Server not initialized, which invites it to handshake and retry a method that will never exist. dispatch_rpc now checks the request against the set of implemented methods before it consults the handshake state, so an unsupported method is reported as unknown in either state. The handshake guard itself is unchanged: an implemented method other than initialize or ping still returns -32002 before initialize. Co-authored-by: Cursor --- CHANGELOG.md | 5 +++++ coding_tools_mcp/protocol.py | 17 ++++++++++++++++- tests/compliance/test_mcp_contract.py | 11 +++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df3329..b74aa62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,11 @@ - `exec_command` and `read_output` tool descriptions now direct clients to redirect very large output to a file and page it with `read_file` / `search_text`. +- A method this server does not implement now returns `-32601` before the + handshake as well as after it. Such a call previously returned `-32002 Server + not initialized`, which tells a client to handshake and retry a method that + will never exist. Implemented methods are unchanged: calling one before + `initialize` still returns `-32002`. ### Fixed diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index 2a7e3b4..d641562 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -7,6 +7,16 @@ PROTOCOL_VERSION = "2025-11-25" SUPPORTED_PROTOCOL_VERSIONS = (PROTOCOL_VERSION, "2025-06-18") +KNOWN_METHODS = frozenset( + { + "initialize", + "notifications/initialized", + "notifications/cancelled", + "ping", + "tools/list", + "tools/call", + } +) def jsonrpc_error( @@ -82,6 +92,9 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None Handshake state lives on ``runtime.initialized``; transports add only their transport-specific framing (session headers, stream handling) around this. + A method this server does not implement is rejected before that state is + consulted, so a client probing for an unsupported method learns the method + is unknown instead of being told to handshake first. Returns None for notifications and requests without an id. """ @@ -90,6 +103,8 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None validate_rpc_envelope(request) method = request["method"] params = rpc_params(request) + if method not in KNOWN_METHODS: + raise JsonRpcError(-32601, f"Unknown method: {method}") if not runtime.initialized and method not in {"initialize", "ping"}: raise JsonRpcError(-32002, "Server not initialized") if method == "initialize": @@ -131,7 +146,7 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None if not isinstance(arguments, dict): raise JsonRpcError(-32602, "tools/call arguments must be an object") result = runtime.call_tool(params["name"], arguments, request_id=request_id) - else: + else: # only reachable if KNOWN_METHODS gains a method without a branch here raise JsonRpcError(-32601, f"Unknown method: {method}") if request_id is None: return None diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 835f6b9..033e2ae 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -1057,6 +1057,17 @@ def test_stdio_replays_duplicate_initialize_after_a_failed_probe(self) -> None: finally: self.stop_process(process) + def test_stdio_reports_unknown_methods_before_initialize(self) -> None: + process = self.start_stdio_server() + try: + rejected = self.stdio_rpc_allow_error( + process, + {"jsonrpc": "2.0", "id": 1, "method": "totally/unknown", "params": {}}, + ) + self.assertEqual(rejected.get("error", {}).get("code"), -32601) + finally: + self.stop_process(process) + def assert_content_text_is_agent_readable(self, result: dict[str, Any]) -> str: structured = result.get("structuredContent") self.assertIsInstance(structured, dict, f"structuredContent must be an object: {result!r}") From 55890990e7acebcd3198b110c2524df2a41d0b7f Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:34:27 +0000 Subject: [PATCH 08/25] Remove the session-scoped default cwd and resolve paths from the workspace root get_default_cwd and set_default_cwd were the only tool-visible session state, and their base could silently reset under a client reconnect. Relative paths now always resolve against the workspace root, so the catalog drops to 18 tools, server_info no longer reports default_cwd, and the Workspace resolvers lose their unused base-directory variants. Co-authored-by: Cursor --- CHANGELOG.md | 15 +++-- README.md | 4 +- README.zh-CN.md | 4 +- SPEC.md | 5 +- coding_tools_mcp/server.py | 73 ++---------------------- coding_tools_mcp/tool_results.py | 6 -- docs/competitive-analysis.md | 2 +- docs/exec-command-recipes.md | 5 +- docs/tools-and-schemas.md | 20 ++----- tests/compliance/mcp_client.py | 2 - tests/compliance/test_mcp_contract.py | 18 ++---- tests/compliance/test_runtime_helpers.py | 17 +++--- tests/test_telemetry.py | 10 ++-- video/src/Promo.tsx | 4 +- 14 files changed, 50 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b74aa62..41bffa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,17 @@ commands. Commands are still bounded by the existing active-count, retained output, byte, timeout, and TTL limits and are terminated when the workspace server shuts down. -- `default_cwd` remains scoped to one MCP transport session and may reset after - reconnect. Tool descriptions now direct remote clients to pass explicit - `path`/`workdir` arguments and include concrete examples for patching and - command continuation. +- **Breaking:** `get_default_cwd` and `set_default_cwd` are removed and the + default catalog is now 18 tools. A relative `path` always resolves against + the workspace root, so there is no session-scoped working directory to set, + read, or lose on reconnect. Pass a workspace-relative `path`, or + `exec_command`'s `workdir`, to target a subdirectory. The `read_file` + `next_action` continuation now repeats the workspace-relative path it was + given rather than one relative to a session cwd, and `server_info` no longer + reports `default_cwd`. +- Tool descriptions now direct remote clients to pass explicit `path`/`workdir` + arguments and include concrete examples for patching and command + continuation. - `kill_command` now declares `kill_wait_ms` (hard-kill escalation wait, default 2000 ms) in its input schema; previously the runtime honored it but schema validation rejected any call that passed it. diff --git a/README.md b/README.md index a178309..459085c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Coding Tools MCP is a **model-neutral coding runtime** served over the search, structured multi-file patches, command execution, interactive sessions, and git — one server that any MCP client can drive. Claude Desktop, Claude Code, Cursor, Cline, or an agent you build yourself all get the same -20 battle-tested tools, confined to one workspace, gated by permission modes. +18 battle-tested tools, confined to one workspace, gated by permission modes. [![Watch the demo](https://img.youtube.com/vi/N9lQaXt1eqQ/maxresdefault.jpg)](https://youtu.be/N9lQaXt1eqQ?si=LyEwvzzQF6QjUxR0) @@ -137,7 +137,7 @@ rollback. | Files & search | `read_file` · `list_dir` · `list_files` · `search_text` · `apply_patch` · `view_image` | | Execution | `exec_command` · `write_stdin` · `read_output` · `kill_command` · `request_permissions` | | Git | `git_status` · `git_diff` · `git_log` · `git_show` · `git_blame` | -| Runtime | `server_info` · `check_exec_environment` · `get_default_cwd` · `set_default_cwd` | +| Runtime | `server_info` · `check_exec_environment` | Root `AGENTS.md`/`CLAUDE.md` files load into the initialize context automatically. Tool `content` is concise agent-facing text; diff --git a/README.zh-CN.md b/README.zh-CN.md index 22acce3..5dbdb83 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,7 +15,7 @@ Coding Tools MCP 是一个**模型中立的编程运行时**,通过 [Model Context Protocol](https://modelcontextprotocol.io) 对外提供服务: 文件读取与搜索、结构化多文件补丁、命令执行、交互式命令、git 操作—— 一个服务器,任何 MCP 客户端都能驱动。Claude Desktop、Claude Code、Cursor、 -Cline,或你自己写的 agent,拿到的都是同一套久经考验的 20 个工具: +Cline,或你自己写的 agent,拿到的都是同一套久经考验的 18 个工具: 限定在单一工作区内,由权限模式层层把关。 [![观看演示](https://img.youtube.com/vi/N9lQaXt1eqQ/maxresdefault.jpg)](https://youtu.be/N9lQaXt1eqQ?si=LyEwvzzQF6QjUxR0) @@ -126,7 +126,7 @@ coding-tools-mcp-desktop | 文件与搜索 | `read_file` · `list_dir` · `list_files` · `search_text` · `apply_patch` · `view_image` | | 执行 | `exec_command` · `write_stdin` · `read_output` · `kill_command` · `request_permissions` | | Git | `git_status` · `git_diff` · `git_log` · `git_show` · `git_blame` | -| 运行时 | `server_info` · `check_exec_environment` · `get_default_cwd` · `set_default_cwd` | +| 运行时 | `server_info` · `check_exec_environment` | 仓库根部的 `AGENTS.md`/`CLAUDE.md` 会自动载入 initialize 上下文。工具的 `content` 是给 agent 看的精炼文本,`structuredContent` 则是完整稳定的机器 diff --git a/SPEC.md b/SPEC.md index 22004e6..a1355f6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -17,10 +17,9 @@ no dynamic `tools/list_changed`, and no required `open_workspace` call. `apply_patch` is the only direct file-write tool. `safe`, `trusted`, and `dangerous` are command permission policies and never alter `tools/list`. -The default catalog contains 20 tools: +The default catalog contains 18 tools: -- runtime/context: `server_info`, `check_exec_environment`, `get_default_cwd`, - `set_default_cwd` +- runtime/context: `server_info`, `check_exec_environment` - workspace inspection: `read_file`, `list_dir`, `list_files`, `search_text` - mutation: `apply_patch` - processes: `exec_command`, `write_stdin`, `read_output`, `kill_command` diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 1bcddd9..3939d2b 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -565,23 +565,6 @@ def _image_content(payload: dict[str, Any]) -> list[dict[str, Any]]: read_only=True, idempotent=True, ), - "get_default_cwd": ToolSpec( - title="Get default cwd", - description=( - "Return the current MCP transport session's default cwd. This value is session-local and may " - "reset after a reconnect." - ), - read_only=True, - idempotent=True, - ), - "set_default_cwd": ToolSpec( - title="Set default cwd", - description=( - "Set the default cwd only for the current MCP transport session. Prefer explicit path/workdir " - "arguments when calls must survive reconnects. Example: {\"path\":\"src\"}." - ), - idempotent=True, - ), "read_file": ToolSpec( title="Read file", description="Read a UTF-8 text file slice inside the configured workspace.", @@ -1109,12 +1092,8 @@ def _reject_unsafe_text(self, raw_path: str) -> PurePosixPath: return pure def resolve_existing(self, raw_path: str = ".") -> ResolvedPath: - return self.resolve_existing_at(self.root, raw_path) - - def resolve_existing_at(self, base: Path, raw_path: str = ".") -> ResolvedPath: pure = self._reject_unsafe_text(raw_path or ".") - base = self._validate_base(base) - candidate = base.joinpath(*pure.parts) + candidate = self.root.joinpath(*pure.parts) try: resolved = candidate.resolve(strict=True) except FileNotFoundError as exc: @@ -1125,14 +1104,10 @@ def resolve_existing_at(self, base: Path, raw_path: str = ".") -> ResolvedPath: return ResolvedPath(normalize_rel_display(resolved, self.root), resolved, True) def resolve_for_write(self, raw_path: str) -> ResolvedPath: - return self.resolve_for_write_at(self.root, raw_path) - - def resolve_for_write_at(self, base: Path, raw_path: str) -> ResolvedPath: pure = self._reject_unsafe_text(raw_path) if pure.name in {"", ".", ".."}: raise ToolFailure("INVALID_ARGUMENT", "Invalid write target.", category="validation") - base = self._validate_base(base) - candidate = base.joinpath(*pure.parts) + candidate = self.root.joinpath(*pure.parts) if candidate.exists() or candidate.is_symlink(): resolved = candidate.resolve(strict=True) if not is_relative_to(resolved, self.root): @@ -1155,17 +1130,6 @@ def resolve_for_write_at(self, base: Path, raw_path: str) -> ResolvedPath: target = resolved_parent.joinpath(*reversed([p.name for p in missing]), candidate.name) return ResolvedPath(normalize_rel_display(target, self.root), target, False) - def _validate_base(self, base: Path) -> Path: - try: - resolved = base.resolve(strict=True) - except FileNotFoundError as exc: - raise ToolFailure("NOT_FOUND", "Default cwd path no longer exists.", category="not_found") from exc - if not resolved.is_dir(): - raise ToolFailure("NOT_A_DIRECTORY", "Default cwd is not a directory.", category="validation") - if not is_relative_to(resolved, self.root): - raise ToolFailure("PATH_OUTSIDE_WORKSPACE", "Default cwd escapes the configured workspace.", category="security") - return resolved - def reject_write_symlink(self, raw_path: str) -> None: pure = self._reject_unsafe_text(raw_path) candidate = self.root.joinpath(*pure.parts) @@ -1351,7 +1315,6 @@ def __init__( self.server_instance_id = self.command_manager.server_instance_id self._set_runtime_dir(self.command_manager.runtime_dir) self.fallback_runtime_dir = self.command_manager.fallback_runtime_dir - self.default_cwd = self.workspace.root self._closed = False self.http_session_id = secrets.token_urlsafe(24) self.protocol_version = PROTOCOL_VERSION @@ -1508,18 +1471,15 @@ def auth_enabled(self) -> bool: def oauth_enabled(self) -> bool: return self.oauth_config is not None - def default_cwd_display(self) -> str: - return normalize_rel_display(self.default_cwd, self.workspace.root) - def resolve_existing(self, raw_path: str = ".") -> ResolvedPath: - return self.workspace.resolve_existing_at(self.default_cwd, raw_path) + return self.workspace.resolve_existing(raw_path) def resolve_for_write(self, raw_path: str) -> ResolvedPath: - return self.workspace.resolve_for_write_at(self.default_cwd, raw_path) + return self.workspace.resolve_for_write(raw_path) def git_path_filter(self, raw_path: str) -> str: if raw_path == ".": - return self.default_cwd_display() + return "." return self.resolve_for_write(raw_path).display def _exec_environment_summary(self) -> dict[str, Any]: @@ -1546,7 +1506,6 @@ def server_info_payload(self) -> dict[str, Any]: "version": __version__, "protocol_version": self.protocol_version, **self._exec_environment_summary(), - "default_cwd": self.default_cwd_display(), "auth_enabled": self.auth_enabled(), "dangerously_skip_all_permissions": self.dangerously_skip_all_permissions, "annotation_override": "fake_readonly" if self.fake_readonly_annotations else None, @@ -1669,22 +1628,6 @@ def check_exec_environment(self, args: dict[str, Any]) -> dict[str, Any]: "warnings": warnings, } - def get_default_cwd(self, args: dict[str, Any]) -> dict[str, Any]: - return { - "workspace": str(self.workspace.root), - "default_cwd": self.default_cwd_display(), - } - - def set_default_cwd(self, args: dict[str, Any]) -> dict[str, Any]: - resolved = self.workspace.resolve_existing(str(args.get("path", "."))) - if not resolved.path.is_dir(): - raise ToolFailure("NOT_A_DIRECTORY", "Default cwd must be a directory.", category="validation") - self.default_cwd = resolved.path - return { - "workspace": str(self.workspace.root), - "default_cwd": resolved.display, - } - def emit_tool_trace(self, name: str, args: dict[str, Any], payload: dict[str, Any], started_at: float) -> None: raw_error = payload.get("error") error = raw_error if isinstance(raw_error, dict) else {} @@ -4581,12 +4524,6 @@ def input_schemas() -> dict[str, dict[str, Any]]: return { "server_info": object_schema(), "check_exec_environment": object_schema(), - "get_default_cwd": object_schema(), - "set_default_cwd": object_schema( - { - "path": {**string, "default": "."}, - } - ), "read_file": object_schema( { "path": {**string, "minLength": 1}, diff --git a/coding_tools_mcp/tool_results.py b/coding_tools_mcp/tool_results.py index 719b0c3..87e7da0 100644 --- a/coding_tools_mcp/tool_results.py +++ b/coding_tools_mcp/tool_results.py @@ -81,10 +81,6 @@ def _render_exec_environment(payload: dict[str, Any]) -> str: return f"Execution environment checked. Landlock: {state}.{suffix}" -def _render_cwd(payload: dict[str, Any]) -> str: - return f"Default working directory: {payload.get('default_cwd', '.')}" - - def _render_read_file(payload: dict[str, Any]) -> str: content = payload.get("content") if not isinstance(content, str): @@ -388,8 +384,6 @@ def _render_image(payload: dict[str, Any]) -> str: _RENDERERS = { "server_info": _render_server_info, "check_exec_environment": _render_exec_environment, - "get_default_cwd": _render_cwd, - "set_default_cwd": _render_cwd, "read_file": _render_read_file, "list_dir": _render_list, "list_files": _render_list, diff --git a/docs/competitive-analysis.md b/docs/competitive-analysis.md index 34fe134..c5cd2c7 100644 --- a/docs/competitive-analysis.md +++ b/docs/competitive-analysis.md @@ -8,7 +8,7 @@ from MCP unit tests alone. | Concern | This runtime in 0.2 | Practical comparison | | --- | --- | --- | -| Tool choice | One stable catalog of 20 low-level coding tools; no profiles or dynamic process tools | A fixed catalog reduces discovery and routing variance, but a host agent can still add its own tools | +| Tool choice | One stable catalog of 18 low-level coding tools; no profiles or dynamic process tools | A fixed catalog reduces discovery and routing variance, but a host agent can still add its own tools | | Editing | `apply_patch` is the sole direct mutation primitive; it stages all files, checks baselines, preserves mode/BOM/newlines, and rolls back partial commits | A whole-file `edit_file` can be simpler for a model, while patching sends fewer unchanged bytes and gives stronger conflict/rollback behavior | | Results | Concise bounded `content`, complete `structuredContent`, image bytes once | Avoids paying model context for duplicated JSON, diffs, and base64 | | Commands | Ten-second default foreground yield; fixed `write_stdin`, `read_output`, and `kill_command`; bounded commands and real POSIX PTY | Short tests normally finish in one call; background/interactive work has explicit next actions | diff --git a/docs/exec-command-recipes.md b/docs/exec-command-recipes.md index 50dd2c0..8e2d3f8 100644 --- a/docs/exec-command-recipes.md +++ b/docs/exec-command-recipes.md @@ -33,9 +33,8 @@ interacts with the process. `read_output` is for paging retained stdout/stderr when a result explicitly says output was truncated (or when compact verbosity was requested). It is not an extra step for every command. -For remote clients, pass `workdir` explicitly whenever location matters. -`set_default_cwd` is scoped to one MCP transport session and may reset when a -client reconnects or initializes again. +Relative paths always resolve against the workspace root, so pass `workdir` +explicitly whenever a command must run somewhere else. Use the external runtime `HOME`, `TMPDIR`, or `cache_dir` reported by `server_info` when you want dependency caches without adding files to the Git worktree. These shell examples assume trusted mode because they use environment expansion: diff --git a/docs/tools-and-schemas.md b/docs/tools-and-schemas.md index 12bf558..d475561 100644 --- a/docs/tools-and-schemas.md +++ b/docs/tools-and-schemas.md @@ -6,14 +6,11 @@ properties, annotations, and error codes with the contract. ## Fixed inventory -The default catalog contains exactly 20 tools: +The default catalog contains exactly 18 tools: - `server_info`: server, workspace, automatic project context, policy, runtime, auth, protocol, and fixed-catalog metadata. - `check_exec_environment`: lightweight execution policy and Landlock status. -- `get_default_cwd`: inspect this MCP transport session's relative-path base. -- `set_default_cwd`: change this MCP transport session's relative-path base; - prefer explicit `path`/`workdir` when reconnects are possible. - `read_file`: stream a bounded UTF-8 range without loading the whole file. - `list_dir`: list immediate or bounded-recursive directory entries. - `list_files`: iterate files with glob, ignore, hidden-file, sort, and cap @@ -33,7 +30,7 @@ The default catalog contains exactly 20 tools: - `view_image`: one MCP image content block plus structured metadata. `view_image` may be disabled when an installation cannot accept binary image -content. That capability gate is not a tool profile. The other 19 tools are +content. That capability gate is not a tool profile. The other 17 tools are always advertised, and `listChanged` is `false`. ## Result envelope @@ -83,7 +80,8 @@ Mode bits, BOM, and newline style are preserved; moves inherit source mode. ## Model-ready examples -Use explicit paths for multi-call workflows: +Every relative path resolves against the workspace root; there is no +session-scoped working directory. Use explicit paths for multi-call workflows: ```json {"cmd":"pytest -q","workdir":".","yield_time_ms":30000} @@ -107,14 +105,8 @@ Page a truncated stream using the returned reference: {"output_ref":"command:abc:stdout","offset":0,"limit":4096} ``` -`set_default_cwd` is only a session-local convenience: - -```json -{"path":"src"} -``` - -It may reset after the client reconnects. `exec_command.workdir` and each -file/Git tool's `path` argument are the reliable source of truth. +`exec_command.workdir` and each file/Git tool's `path` argument are how a call +targets a subdirectory; both are still confined to the workspace. ## Command and output behavior diff --git a/tests/compliance/mcp_client.py b/tests/compliance/mcp_client.py index 5793a5c..c1089e9 100644 --- a/tests/compliance/mcp_client.py +++ b/tests/compliance/mcp_client.py @@ -25,8 +25,6 @@ REQUIRED_TOOLS = ( "server_info", "check_exec_environment", - "get_default_cwd", - "set_default_cwd", "read_file", "list_dir", "list_files", diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 033e2ae..2ab2988 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -80,7 +80,6 @@ def test_command_handles_have_no_legacy_session_aliases(self) -> None: def test_high_confusion_tools_include_model_ready_examples(self) -> None: tools = {str(tool.get("name")): tool for tool in self.client.list_tools()} expected_fragments = { - "set_default_cwd": ("session", "workdir", '"path":"src"'), "apply_patch": ("*** Begin Patch", "*** Update File"), "exec_command": ("workdir", "command_id", '"yield_time_ms":30000'), "write_stdin": ("command_id", '"chars":""'), @@ -93,13 +92,8 @@ def test_high_confusion_tools_include_model_ready_examples(self) -> None: with self.subTest(tool=name, fragment=fragment): self.assertIn(fragment, description) - def test_http_sessions_isolate_cwd_but_share_workspace_commands(self) -> None: - self.client.call_tool("set_default_cwd", {"path": "src"}) + def test_http_sessions_share_workspace_commands(self) -> None: with MCPClient(self.workspace.root, url=self.client.url) as sibling: - sibling_cwd = self.assert_tool_success(sibling.call_tool("get_default_cwd", {})) - self.assertEqual(sibling_cwd.get("default_cwd"), ".") - sibling.call_tool("set_default_cwd", {"path": "test"}) - started = self.client.call_tool( "exec_command", {"cmd": "sleep 1", "timeout_ms": 5000, "yield_time_ms": 0}, @@ -111,10 +105,10 @@ def test_http_sessions_isolate_cwd_but_share_workspace_commands(self) -> None: sibling.call_tool("write_stdin", {"command_id": command_id, "chars": "", "yield_time_ms": 0}) ) self.assertEqual(polled.get("command_id"), command_id) - sibling.call_tool("kill_command", {"command_id": command_id, "signal": "KILL"}) - - original_cwd = self.assert_tool_success(self.client.call_tool("get_default_cwd", {})) - self.assertEqual(original_cwd.get("default_cwd"), "src") + killed = self.assert_tool_success( + sibling.call_tool("kill_command", {"command_id": command_id, "signal": "KILL"}) + ) + self.assertIn(killed.get("status"), {"killed", "exited"}) def test_http_session_delete_does_not_terminate_workspace_command(self) -> None: with MCPClient(self.workspace.root, url=self.client.url) as owner: @@ -164,8 +158,6 @@ def test_tool_annotations_match_mcp_sdk_hint_shape(self) -> None: expected = { "server_info": (True, False, True, False), "check_exec_environment": (True, False, True, False), - "get_default_cwd": (True, False, True, False), - "set_default_cwd": (False, False, True, False), "read_file": (True, False, True, False), "list_dir": (True, False, True, False), "list_files": (True, False, True, False), diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index dad6bc5..cb779a3 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -1016,7 +1016,7 @@ def test_read_file_truncation_is_visible_with_continuation(self) -> None: self.assertIn("line-2000", model_text) self.assertNotIn("line-2001\n", model_text) - def test_read_file_continuation_preserves_default_cwd_relative_path(self) -> None: + def test_read_file_continuation_preserves_workspace_relative_path(self) -> None: with TemporaryDirectory() as tmp: workspace = Path(tmp) nested = workspace / "nested" @@ -1026,16 +1026,15 @@ def test_read_file_continuation_preserves_default_cwd_relative_path(self) -> Non encoding="utf-8", ) runtime = Runtime(workspace) - runtime.set_default_cwd({"path": "nested"}) first = runtime.call_tool( "read_file", - {"path": "long.txt", "max_bytes": 16}, + {"path": "nested/long.txt", "max_bytes": 16}, ) first_payload = first["structuredContent"] action = first_payload.get("next_action") self.assertIsInstance(action, dict) self.assertEqual(action.get("tool"), "read_file") - self.assertEqual(action.get("arguments", {}).get("path"), "long.txt") + self.assertEqual(action.get("arguments", {}).get("path"), "nested/long.txt") second = runtime.call_tool(action["tool"], action["arguments"]) self.assertIs(second.get("isError"), False) self.assertEqual( @@ -1425,7 +1424,7 @@ def test_output_retention_counters_and_server_info_track_evicted_output(self) -> server_module.COMMAND_BUFFER_BYTES // 8, ) - def test_default_cwd_and_git_convenience_tools(self) -> None: + def test_git_convenience_tools(self) -> None: if server_module.shutil.which("git") is None: self.skipTest("git is not available") with TemporaryDirectory() as tmp: @@ -1444,9 +1443,7 @@ def test_default_cwd_and_git_convenience_tools(self) -> None: self.skipTest(f"git fixture setup failed: {completed.stderr.strip()}") runtime = Runtime(workspace) - cwd = runtime.set_default_cwd({"path": "src"}) - self.assertEqual(cwd.get("default_cwd"), "src") - read = runtime.read_file({"path": "hello.txt"}) + read = runtime.read_file({"path": "src/hello.txt"}) self.assertEqual(read.get("content"), "hello\n") log = runtime.git_log({"max_count": 5}) @@ -1457,12 +1454,12 @@ def test_default_cwd_and_git_convenience_tools(self) -> None: self.assertTrue(show.get("is_repo")) self.assertIn("initial commit", show.get("content", "")) - blame = runtime.git_blame({"path": "hello.txt", "max_lines": 5}) + blame = runtime.git_blame({"path": "src/hello.txt", "max_lines": 5}) self.assertTrue(blame.get("is_repo")) self.assertEqual(blame.get("lines", [])[0].get("content"), "hello") with self.assertRaises(ToolFailure): - runtime.set_default_cwd({"path": "../outside"}) + runtime.git_blame({"path": "../outside", "max_lines": 5}) def test_boundary_regressions_for_aliases_and_command_scanning(self) -> None: with TemporaryDirectory() as tmp: diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 39cc030..10d6fea 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -93,7 +93,7 @@ def test_disabled_session_never_reaches_the_sender(self) -> None: with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp)) _initialize(runtime) - runtime.call_tool("get_default_cwd", {}) + runtime.call_tool("check_exec_environment", {}) runtime.call_tool("read_file", {"path": "missing.txt"}) runtime.close() get_sender.assert_not_called() @@ -126,7 +126,7 @@ def _run_probe_session() -> _CapturingSender: (workspace / f"{marker}.txt").write_text("leakprobe-content\n", encoding="utf-8") runtime = Runtime(workspace) _initialize(runtime, client_name="clientinfo-probe") - runtime.call_tool("get_default_cwd", {}) + runtime.call_tool("check_exec_environment", {}) runtime.call_tool("read_file", {"path": f"{marker}-missing.txt"}) runtime.call_tool("read_file", {"path": f"{marker}-missing.txt"}) runtime.close() @@ -173,8 +173,8 @@ def test_session_events_carry_the_closed_schema(self) -> None: self.assertEqual(summaries["read_file"]["calls"], 2) self.assertEqual(summaries["read_file"]["ok"], 0) self.assertEqual(summaries["read_file"]["err_NOT_FOUND"], 2) - self.assertEqual(summaries["get_default_cwd"]["calls"], 1) - self.assertEqual(summaries["get_default_cwd"]["ok"], 1) + self.assertEqual(summaries["check_exec_environment"]["calls"], 1) + self.assertEqual(summaries["check_exec_environment"]["ok"], 1) end = by_name["session_end"][0]["properties"] assert isinstance(end, dict) @@ -187,7 +187,7 @@ def test_sessions_without_initialize_emit_nothing(self) -> None: with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp)) - runtime.call_tool("get_default_cwd", {}) + runtime.call_tool("check_exec_environment", {}) runtime.call_tool("read_file", {"path": "missing.txt"}) runtime.close() self.assertEqual(sender.events, []) diff --git a/video/src/Promo.tsx b/video/src/Promo.tsx index 5da5fc0..c2c90f3 100644 --- a/video/src/Promo.tsx +++ b/video/src/Promo.tsx @@ -165,7 +165,7 @@ const ClientsScene: React.FC = () => {
- One MCP server · every AI client · the same 20 tools + One MCP server · every AI client · the same 18 tools
@@ -408,7 +408,7 @@ const CtaScene: React.FC = () => ( >
- −37% tool-result bytes · 20 tools · Apache-2.0 · PyPI + npm + −37% tool-result bytes · 18 tools · Apache-2.0 · PyPI + npm
From 90a2340f459c5c1696acdeb6f1faeef4d3fd3fae Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:48:10 +0000 Subject: [PATCH 09/25] Share one runtime safely across concurrent clients One runtime now answers requests from several clients at once, so the per-request state it kept on itself is gone. The request-id to command_id map was keyed by the client's own JSON-RPC id, so two clients that both used id 1 could cancel each other's commands; notifications/cancelled is still accepted and answered with silence, but it no longer terminates a command (issue #48 tracks the responsiveness that costs). The threading local that fed the map, cancel_request, and the now-unreachable cancel_command go with it, and call_tool loses its request_id argument. A frozen RequestContext carries the cross-cutting facts instead. Transports build one per request in dispatch_rpc and tools/call hands it to call_tool as a defaulted keyword argument, so a direct call_tool(name, arguments) still works. The runtime does not read it: it only passes it to emit_tool_trace for the observability work that will consume it. Freezing is shallow, so the identity fields stay None until a validated immutable copy exists to put in them. Two shared structures are hardened. The runtime directory is resolved to the primary or fallback tree exactly once under a lock, so a later mkdir failure reports RUNTIME_DIR_UNWRITABLE instead of moving HOME, TMPDIR, and the cache directory underneath a running command. The non-git diff fallback snapshots its patch baselines under the patch lock rather than iterating a dict another thread may be writing. Co-authored-by: Cursor --- CHANGELOG.md | 14 +++ coding_tools_mcp/protocol.py | 30 ++++- coding_tools_mcp/server.py | 137 ++++++++++++----------- coding_tools_mcp/transport_stdio.py | 6 +- tests/compliance/test_mcp_contract.py | 21 ++++ tests/compliance/test_runtime_helpers.py | 29 +++++ 6 files changed, 164 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bffa2..32e74ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ commands. Commands are still bounded by the existing active-count, retained output, byte, timeout, and TTL limits and are terminated when the workspace server shuts down. +- **Breaking:** `notifications/cancelled` no longer terminates the command the + cancelled request started. The notification is still accepted and, as + before, answered with no response. A command outlives the request that + started it and is shared by every client of the workspace, and the mapping + was keyed by the client's own JSON-RPC id, so two clients that both used + `id: 1` could cancel each other's commands. Terminate a command with + `kill_command`; the reduced cancellation responsiveness is tracked in issue + #48. - **Breaking:** `get_default_cwd` and `set_default_cwd` are removed and the default catalog is now 18 tools. A relative `path` always resolves against the workspace root, so there is no session-scoped working directory to set, @@ -50,6 +58,12 @@ not initialized`, which tells a client to handshake and retry a method that will never exist. Implemented methods are unchanged: calling one before `initialize` still returns `-32002`. +- Runtime state a shared server exposes to concurrent requests is hardened: + the runtime directory (and the `HOME`, `TMPDIR`, and cache directories under + it) is resolved to the primary or fallback location exactly once, so a later + failure reports `RUNTIME_DIR_UNWRITABLE` instead of moving a running + command's directories, and the non-git diff fallback snapshots its patch + baselines under the patch lock. ### Fixed diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index d641562..03ce2e2 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass from typing import Any from .errors import JsonRpcError @@ -7,6 +9,7 @@ PROTOCOL_VERSION = "2025-11-25" SUPPORTED_PROTOCOL_VERSIONS = (PROTOCOL_VERSION, "2025-06-18") +LEGACY_ERA = "legacy" KNOWN_METHODS = frozenset( { "initialize", @@ -19,6 +22,23 @@ ) +@dataclass(frozen=True) +class RequestContext: + """Per-request facts that transports hand to the runtime. + + One runtime serves concurrent clients, so a request carries its own + context instead of parking it on runtime state. ``frozen`` freezes only + the top level: ``client_info`` and ``client_capabilities`` must hold + immutable values, so they stay ``None`` until the negotiated identity is + validated and copied into them. + """ + + era: str = LEGACY_ERA + protocol_version: str = PROTOCOL_VERSION + client_info: Mapping[str, Any] | None = None + client_capabilities: Mapping[str, Any] | None = None + + def jsonrpc_error( request_id: str | int | None, code: int, message: str, data: Any = None ) -> dict[str, Any]: @@ -99,6 +119,7 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None """ request_id = request.get("id") + context = RequestContext(era=LEGACY_ERA, protocol_version=runtime.protocol_version) try: validate_rpc_envelope(request) method = request["method"] @@ -131,9 +152,10 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None elif method == "notifications/initialized": return None elif method == "notifications/cancelled": - cancelled_request_id = params.get("requestId") - if isinstance(cancelled_request_id, (str, int)) and not isinstance(cancelled_request_id, bool): - runtime.cancel_request(cancelled_request_id) + # Accepted and acknowledged by staying silent. A command outlives + # the request that started it and is shared with every other + # client of this workspace, so cancelling a request must not kill + # it; clients terminate a command with kill_command. return None elif method == "ping": result = {} @@ -145,7 +167,7 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None arguments = params.get("arguments") or {} if not isinstance(arguments, dict): raise JsonRpcError(-32602, "tools/call arguments must be an object") - result = runtime.call_tool(params["name"], arguments, request_id=request_id) + result = runtime.call_tool(params["name"], arguments, context=context) else: # only reachable if KNOWN_METHODS gains a method without a branch here raise JsonRpcError(-32601, f"Unknown method: {method}") if request_id is None: diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 3939d2b..10468a2 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -70,6 +70,7 @@ from .protocol import ( PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, + RequestContext, dispatch_rpc, jsonrpc_error, protocol_version_is_supported, @@ -1315,6 +1316,8 @@ def __init__( self.server_instance_id = self.command_manager.server_instance_id self._set_runtime_dir(self.command_manager.runtime_dir) self.fallback_runtime_dir = self.command_manager.fallback_runtime_dir + self._runtime_dir_lock = threading.Lock() + self._runtime_dir_resolved = False self._closed = False self.http_session_id = secrets.token_urlsafe(24) self.protocol_version = PROTOCOL_VERSION @@ -1327,9 +1330,6 @@ def __init__( self.project_context: ProjectContext = ( project_context if project_context is not None else load_project_context(self.workspace.root) ) - self.request_commands: dict[str | int, str] = {} - self.request_commands_lock = threading.Lock() - self.request_context = threading.local() self.initialized = False self.telemetry = SessionTelemetry(permission_mode=self.permission_mode, transport=transport) self._tool_handlers = {name: getattr(self, name) for name in TOOL_REGISTRY} @@ -1368,36 +1368,57 @@ def starting_commands(self) -> int: def starting_commands(self, value: int) -> None: self.command_manager.starting_commands = value + def _create_runtime_dirs(self, runtime_dir: Path) -> str | None: + """Create one runtime tree, reporting failure instead of raising.""" + + try: + for path in ( + runtime_dir.parent, + runtime_dir, + runtime_dir / "home", + runtime_dir / "tmp", + runtime_dir / "cache", + ): + path.mkdir(parents=True, mode=0o700, exist_ok=True) + if os.name != "nt": + try: + path.chmod(0o700) + except OSError: + pass + except OSError as exc: + return f"{runtime_dir}: {exc}" + return None + def _ensure_runtime_dirs(self) -> None: - candidates = [self.runtime_dir] - if self.fallback_runtime_dir is not None and self.fallback_runtime_dir not in candidates: - candidates.append(self.fallback_runtime_dir) - errors: list[str] = [] - for runtime_dir in candidates: - self._set_runtime_dir(runtime_dir) - try: - for path in ( - self.runtime_dir.parent, - self.runtime_dir, - self.home_dir, - self.tmp_dir, - self.cache_dir, - ): - path.mkdir(parents=True, mode=0o700, exist_ok=True) - if os.name != "nt": - try: - path.chmod(0o700) - except OSError: - pass - return - except OSError as exc: - errors.append(f"{runtime_dir}: {exc}") - raise ToolFailure( - "RUNTIME_DIR_UNWRITABLE", - "Runtime directory could not be created outside the workspace.", - category="runtime", - details={"attempted": errors}, - ) + """Create the runtime directories, choosing which tree to use only once. + + The first call picks the primary directory or, if that one cannot be + created, the fallback. Every later call re-creates that same tree and + fails instead of switching: concurrent clients share one runtime, and + a command reading HOME or TMPDIR must never see them move to another + directory mid-flight. + """ + + with self._runtime_dir_lock: + resolved = self._runtime_dir_resolved + candidates = [self.runtime_dir] + if not resolved and self.fallback_runtime_dir is not None and self.fallback_runtime_dir not in candidates: + candidates.append(self.fallback_runtime_dir) + errors: list[str] = [] + for runtime_dir in candidates: + error = self._create_runtime_dirs(runtime_dir) + if error is None: + if not resolved: + self._set_runtime_dir(runtime_dir) + self._runtime_dir_resolved = True + return + errors.append(error) + raise ToolFailure( + "RUNTIME_DIR_UNWRITABLE", + "Runtime directory could not be created outside the workspace.", + category="runtime", + details={"attempted": errors}, + ) def command_home_dir(self) -> Path: return self.home_dir @@ -1539,7 +1560,7 @@ def call_tool( name: str, arguments: dict[str, Any] | None, *, - request_id: str | int | None = None, + context: RequestContext | None = None, ) -> dict[str, Any]: started_at = time.time() args = arguments or {} @@ -1549,16 +1570,9 @@ def call_tool( spec = TOOL_REGISTRY[name] validate_arguments(name, args) try: - self.request_context.request_id = request_id - try: - payload = handler(args) - finally: - if request_id is not None: - with self.request_commands_lock: - self.request_commands.pop(request_id, None) - self.request_context.request_id = None + payload = handler(args) payload.setdefault("ok", True) - self.emit_tool_trace(name, args, payload, started_at) + self.emit_tool_trace(name, args, payload, started_at, context=context) content = spec.content_builder(payload) if spec.content_builder else None return make_tool_result(name, payload, is_error=payload.get("ok") is False, content=content) except ToolFailure as exc: @@ -1587,7 +1601,7 @@ def call_tool( } if exc.code == "ELICITATION_UNSUPPORTED": payload["status"] = "unsupported" - self.emit_tool_trace(name, args, payload, started_at) + self.emit_tool_trace(name, args, payload, started_at, context=context) return make_tool_result(name, payload, is_error=True) except Exception as exc: # noqa: BLE001 - tool failures must stay structured payload = { @@ -1602,7 +1616,7 @@ def call_tool( } if spec.error_status: payload["status"] = spec.error_status - self.emit_tool_trace(name, args, payload, started_at) + self.emit_tool_trace(name, args, payload, started_at, context=context) return make_tool_result(name, payload, is_error=True) def server_info(self, args: dict[str, Any]) -> dict[str, Any]: @@ -1628,7 +1642,17 @@ def check_exec_environment(self, args: dict[str, Any]) -> dict[str, Any]: "warnings": warnings, } - def emit_tool_trace(self, name: str, args: dict[str, Any], payload: dict[str, Any], started_at: float) -> None: + def emit_tool_trace( + self, + name: str, + args: dict[str, Any], + payload: dict[str, Any], + started_at: float, + *, + context: RequestContext | None = None, + ) -> None: + # `context` carries the per-request facts telemetry will label traces + # with once observability is wired to it; nothing reads it yet. raw_error = payload.get("error") error = raw_error if isinstance(raw_error, dict) else {} duration_ms = int((time.time() - started_at) * 1000) @@ -2373,10 +2397,6 @@ def exec_command(self, args: dict[str, Any]) -> dict[str, Any]: except OSError: pass assert command is not None - request_id = getattr(self.request_context, "request_id", None) - if isinstance(request_id, (str, int)) and not isinstance(request_id, bool): - with self.request_commands_lock: - self.request_commands[request_id] = command.command_id start_reader_threads(command) start_command_watchdog(command) try: @@ -3001,21 +3021,6 @@ def kill_command(self, args: dict[str, Any]) -> dict[str, Any]: self.commands.pop(command_id, None) return payload - def cancel_command(self, command_id: str) -> None: - with self.commands_lock: - command = self.commands.pop(command_id, None) - if command is None: - return - command.refresh_status() - if command.process.poll() is None: - terminate_process_group(command.process, signal.SIGTERM) - - def cancel_request(self, request_id: str | int) -> None: - with self.request_commands_lock: - command_id = self.request_commands.get(request_id) - if command_id is not None: - self.cancel_command(command_id) - def _get_command(self, command_id: str) -> CommandRun: self._prune_commands() with self.commands_lock: @@ -3127,7 +3132,9 @@ def _fallback_diff(self, path_filters: list[str], max_bytes: int) -> dict[str, A selected = set(path_filters) chunks: list[str] = [] files: list[dict[str, Any]] = [] - for rel, before in sorted(self.patch_baselines.items()): + with self.patch_lock: + baselines = sorted(self.patch_baselines.items()) + for rel, before in baselines: if selected and rel not in selected: continue current_path = self.workspace.resolve_for_write(rel).path diff --git a/coding_tools_mcp/transport_stdio.py b/coding_tools_mcp/transport_stdio.py index 387d226..b03a7db 100644 --- a/coding_tools_mcp/transport_stdio.py +++ b/coding_tools_mcp/transport_stdio.py @@ -4,7 +4,7 @@ import sys from typing import Any, Protocol, TextIO -from .protocol import dispatch_rpc, invalid_request_response, jsonrpc_error +from .protocol import RequestContext, dispatch_rpc, invalid_request_response, jsonrpc_error class StdioRuntime(Protocol): @@ -20,11 +20,9 @@ def call_tool( name: str, arguments: dict[str, Any], *, - request_id: str | int | None = None, + context: RequestContext | None = None, ) -> dict[str, Any]: ... - def cancel_request(self, request_id: str | int) -> None: ... - def close(self) -> None: ... diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 2ab2988..4aa1343 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -130,6 +130,27 @@ def test_http_session_delete_does_not_terminate_workspace_command(self) -> None: ) self.assertIn(killed.get("status"), {"killed", "exited"}) + def test_cancel_notification_leaves_the_running_command_alone(self) -> None: + started = self.assert_tool_success( + self.client.call_tool( + "exec_command", + {"cmd": "sleep 5", "timeout_ms": 10000, "yield_time_ms": 0}, + ) + ) + command_id = started.get("command_id") + self.assertIsInstance(command_id, str) + + self.client.notify("notifications/cancelled", {"requestId": self.client.request_id}) + + polled = self.assert_tool_success( + self.client.call_tool("write_stdin", {"command_id": command_id, "chars": "", "yield_time_ms": 0}) + ) + self.assertEqual(polled.get("status"), "running") + killed = self.assert_tool_success( + self.client.call_tool("kill_command", {"command_id": command_id, "signal": "KILL"}) + ) + self.assertIn(killed.get("status"), {"killed", "exited"}) + def test_tools_list_excludes_forbidden_product_layer_tools(self) -> None: names = {str(tool.get("name", "")) for tool in self.client.list_tools()} exact_forbidden = sorted(names & FORBIDDEN_TOOL_NAMES) diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index cb779a3..1040c91 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -489,6 +489,35 @@ def test_command_env_uses_external_home_tmp_and_cache_without_ecosystem_cache_va self.assertTrue(runtime.cache_dir.is_dir()) self.assertFalse((workspace / ".coding-tools").exists()) + def test_runtime_dirs_resolve_once_and_never_move_afterwards(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + runtime = Runtime(workspace) + unwritable = root / "unwritable" + unwritable.write_text("not a directory", encoding="utf-8") + fallback = root / "fallback" / "instance" + runtime._set_runtime_dir(unwritable / "instance") + runtime.fallback_runtime_dir = fallback + + runtime._ensure_runtime_dirs() + + self.assertEqual(runtime.runtime_dir, fallback) + self.assertEqual(runtime.command_home_dir(), fallback / "home") + self.assertEqual(runtime.command_tmp_dir(), fallback / "tmp") + self.assertTrue(runtime.cache_dir.is_dir()) + + shutil.rmtree(fallback.parent) + (root / "fallback").write_text("not a directory", encoding="utf-8") + runtime.fallback_runtime_dir = root / "second-fallback" / "instance" + with self.assertRaises(ToolFailure) as cm: + runtime._command_env({}) + + self.assertEqual(cm.exception.code, "RUNTIME_DIR_UNWRITABLE") + self.assertEqual(runtime.runtime_dir, fallback) + self.assertFalse((root / "second-fallback").exists()) + def test_runtime_and_server_info_do_not_create_exec_dirs(self) -> None: with TemporaryDirectory() as tmp: workspace = Path(tmp) From 20b8a6d2f59e304dc85180a59b4206a5efe9e81c Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:00:27 +0000 Subject: [PATCH 10/25] Serve 2026-07-28 stateless requests alongside the legacy handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP 2026-07-28 drops the initialize handshake: a request states its own protocol version, client capabilities, and optional client identity in params._meta and is served on its own. Decide the era from the request before the initialized guard runs, so such a request never needs a handshake it cannot perform, and validate its _meta before dispatching. The signal is deliberately narrow. Only the modern protocol version key counts, so a legacy request carrying an unrelated _meta entry such as progressToken keeps its old path, and initialize is always a handshake whatever its _meta says. The two eras also keep their version lists apart: a legacy version named in _meta is as unsupported as any other, and -32022 offers back only the versions the stateless path accepts. initialize itself now downgrades instead of failing. The handshake spec asks a server to answer an unsupported protocolVersion with one of its own, and returning -32602 turned a client that guessed wrong — or that tried 2026-07-28 as a handshake — into a connection failure. Co-authored-by: Cursor --- CHANGELOG.md | 22 ++ coding_tools_mcp/protocol.py | 279 +++++++++++++++++++------- coding_tools_mcp/server.py | 14 +- coding_tools_mcp/transport_stdio.py | 2 + tests/compliance/test_mcp_contract.py | 240 ++++++++++++++++++++-- 5 files changed, 466 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e74ce..5c41ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,28 @@ failure reports `RUNTIME_DIR_UNWRITABLE` instead of moving a running command's directories, and the non-git diff fallback snapshots its patch baselines under the patch lock. +- **Behavior change:** `initialize` no longer fails with `-32602` when a client + asks for a `protocolVersion` this server does not speak. As the handshake + spec requires, the server now answers with an `InitializeResult` naming the + newest version it does speak (`2025-11-25`); a client that asks for a + supported version still gets that version back. Asking to handshake with + `2026-07-28` downgrades the same way, because that protocol states its + version per request instead of negotiating one. + +### Added + +- Support for MCP `2026-07-28`, which serves a request without a handshake. + Such a request states its own protocol version in `params._meta` + (`io.modelcontextprotocol/protocolVersion` and + `io.modelcontextprotocol/clientCapabilities` are required, + `io.modelcontextprotocol/clientInfo` is optional) and may call `ping`, + `tools/list`, and `tools/call` immediately. A `_meta` version this server + does not speak is answered with `-32022` and the versions it does + (`data.supported`); a missing or mistyped required `_meta` field is answered + with `-32602`. Available over STDIO in this change; HTTP support follows. + Requests without that `_meta` key — including legacy requests that carry + `_meta.progressToken`, and every `initialize` — keep the handshake behavior + they had. ### Fixed diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index 03ce2e2..c83e2d6 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -7,9 +8,22 @@ from .errors import JsonRpcError -PROTOCOL_VERSION = "2025-11-25" -SUPPORTED_PROTOCOL_VERSIONS = (PROTOCOL_VERSION, "2025-06-18") +# The two eras negotiate their version through different channels and must not +# borrow each other's values: legacy versions are agreed once by ``initialize``, +# modern versions travel in the ``_meta`` of every request. +LEGACY_PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18") +LATEST_LEGACY_PROTOCOL_VERSION = LEGACY_PROTOCOL_VERSIONS[0] +MODERN_PROTOCOL_VERSIONS = ("2026-07-28",) LEGACY_ERA = "legacy" +MODERN_ERA = "modern" + +META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion" +META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities" +META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo" + +UNSUPPORTED_PROTOCOL_VERSION = -32022 +MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 + KNOWN_METHODS = frozenset( { "initialize", @@ -20,6 +34,14 @@ "tools/call", } ) +MODERN_METHODS = frozenset( + { + "notifications/cancelled", + "ping", + "tools/list", + "tools/call", + } +) @dataclass(frozen=True) @@ -28,13 +50,13 @@ class RequestContext: One runtime serves concurrent clients, so a request carries its own context instead of parking it on runtime state. ``frozen`` freezes only - the top level: ``client_info`` and ``client_capabilities`` must hold - immutable values, so they stay ``None`` until the negotiated identity is - validated and copied into them. + the top level: ``client_info`` and ``client_capabilities`` hold deep copies + of the validated ``_meta`` objects so a later mutation of the request body + cannot reach into a context that has already been handed on. """ era: str = LEGACY_ERA - protocol_version: str = PROTOCOL_VERSION + protocol_version: str = LATEST_LEGACY_PROTOCOL_VERSION client_info: Mapping[str, Any] | None = None client_capabilities: Mapping[str, Any] | None = None @@ -86,16 +108,18 @@ def rpc_params(request: dict[str, Any]) -> dict[str, Any]: def validate_initialize_params(params: dict[str, Any]) -> str: + """Negotiate the legacy handshake version, downgrading what we cannot speak. + + The handshake spec requires the server to answer a version it does not + support with one it does, so every unsupported value — including the modern + ``2026-07-28``, which is carried per request instead of negotiated — comes + back as the newest legacy version rather than as an error. + """ + requested = params.get("protocolVersion") - if requested is None: - return PROTOCOL_VERSION - if not protocol_version_is_supported(requested): - raise JsonRpcError( - -32602, - "Unsupported MCP protocol version", - {"supported": list(SUPPORTED_PROTOCOL_VERSIONS), "received": requested}, - ) - return str(requested) + if legacy_protocol_version_is_supported(requested): + return str(requested) + return LATEST_LEGACY_PROTOCOL_VERSION def validate_initialize_request(request: dict[str, Any]) -> None: @@ -103,75 +127,188 @@ def validate_initialize_request(request: dict[str, Any]) -> None: raise JsonRpcError(-32600, "initialize must be a JSON-RPC request with a non-null id") -def protocol_version_is_supported(version: Any) -> bool: - return isinstance(version, str) and version in SUPPORTED_PROTOCOL_VERSIONS +def legacy_protocol_version_is_supported(version: Any) -> bool: + return isinstance(version, str) and version in LEGACY_PROTOCOL_VERSIONS + + +def request_era(method: str, params: Mapping[str, Any]) -> str: + """Decide which protocol era a request belongs to. + + The only signal is a ``_meta`` carrying the modern protocol version key. + ``initialize`` is the one exception and is always legacy: a client that + sends a handshake is asking for one, whatever its ``_meta`` says. Legacy + requests carry unrelated ``_meta`` entries such as ``progressToken``, so the + key must be present, not merely the ``_meta`` object. + """ + + if method == "initialize": + return LEGACY_ERA + meta = params.get("_meta") + if isinstance(meta, dict) and META_PROTOCOL_VERSION in meta: + return MODERN_ERA + return LEGACY_ERA + + +def modern_request_context(params: Mapping[str, Any]) -> RequestContext: + """Validate a modern request's ``_meta`` and turn it into a context. + + Only called once :func:`request_era` has found the protocol version key, so + ``_meta`` is known to be an object that carries it. + """ + + meta = params["_meta"] + version = meta[META_PROTOCOL_VERSION] + if not isinstance(version, str): + raise JsonRpcError( + -32602, + f"{META_PROTOCOL_VERSION} must be a string", + {"reason": "protocol_version"}, + ) + if version not in MODERN_PROTOCOL_VERSIONS: + raise JsonRpcError( + UNSUPPORTED_PROTOCOL_VERSION, + f"Unsupported MCP protocol version in _meta: {version}", + {"supported": list(MODERN_PROTOCOL_VERSIONS), "received": version}, + ) + capabilities = meta.get(META_CLIENT_CAPABILITIES) + if not isinstance(capabilities, dict): + raise JsonRpcError( + -32602, + f"{META_CLIENT_CAPABILITIES} is required and must be an object", + {"reason": "client_capabilities"}, + ) + client_info: Mapping[str, Any] | None = None + if META_CLIENT_INFO in meta: + declared = meta[META_CLIENT_INFO] + if not isinstance(declared, dict): + raise JsonRpcError( + -32602, + f"{META_CLIENT_INFO} must be an object when present", + {"reason": "client_info"}, + ) + client_info = copy.deepcopy(declared) + return RequestContext( + era=MODERN_ERA, + protocol_version=version, + client_info=client_info, + client_capabilities=copy.deepcopy(capabilities), + ) def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None: """Dispatch one MCP JSON-RPC request against a runtime, shared by all transports. - Handshake state lives on ``runtime.initialized``; transports add only their - transport-specific framing (session headers, stream handling) around this. - A method this server does not implement is rejected before that state is - consulted, so a client probing for an unsupported method learns the method - is unknown instead of being told to handshake first. - Returns None for notifications and requests without an id. + The era is decided first, from the request itself: a modern request states + its protocol version per request and never touches the handshake state a + legacy client builds up on ``runtime.initialized``. Transports add only + their transport-specific framing (session headers, stream handling) around + this. Returns None for notifications and requests without an id. """ request_id = request.get("id") - context = RequestContext(era=LEGACY_ERA, protocol_version=runtime.protocol_version) try: validate_rpc_envelope(request) method = request["method"] params = rpc_params(request) - if method not in KNOWN_METHODS: - raise JsonRpcError(-32601, f"Unknown method: {method}") - if not runtime.initialized and method not in {"initialize", "ping"}: - raise JsonRpcError(-32002, "Server not initialized") - if method == "initialize": - validate_initialize_request(request) - negotiated_version = validate_initialize_params(params) - if runtime.initialized: - # Some connectors send a second initialize on one persistent - # STDIO process. Rejecting it fails their tool scan even though - # the session is healthy, so replay the negotiated handshake - # instead. The initializer is not run again, so no session - # state is reset by a repeat. - if negotiated_version != runtime.protocol_version: - raise JsonRpcError( - -32600, - "Server is already initialized with a different protocol version", - {"expected": runtime.protocol_version, "received": negotiated_version}, - ) - result = runtime.initialize_result() - else: - runtime.protocol_version = negotiated_version - client_info = params.get("clientInfo") - result = runtime.initialize(client_info if isinstance(client_info, dict) else None) - runtime.initialized = True - elif method == "notifications/initialized": - return None - elif method == "notifications/cancelled": - # Accepted and acknowledged by staying silent. A command outlives - # the request that started it and is shared with every other - # client of this workspace, so cancelling a request must not kill - # it; clients terminate a command with kill_command. - return None - elif method == "ping": - result = {} - elif method == "tools/list": - result = runtime.list_tools() - elif method == "tools/call": - if not isinstance(params.get("name"), str): - raise JsonRpcError(-32602, "tools/call requires a tool name") - arguments = params.get("arguments") or {} - if not isinstance(arguments, dict): - raise JsonRpcError(-32602, "tools/call arguments must be an object") - result = runtime.call_tool(params["name"], arguments, context=context) - else: # only reachable if KNOWN_METHODS gains a method without a branch here - raise JsonRpcError(-32601, f"Unknown method: {method}") - if request_id is None: + if request_era(method, params) == MODERN_ERA: + context = modern_request_context(params) + result = _dispatch_modern(runtime, method, params, context) + else: + context = RequestContext(era=LEGACY_ERA, protocol_version=runtime.protocol_version) + result = _dispatch_legacy(runtime, request, method, params, context) + if result is None or request_id is None: return None return {"jsonrpc": "2.0", "id": request_id, "result": result} except JsonRpcError as exc: return jsonrpc_error(response_id(request), exc.code, exc.message, exc.data) + + +def _dispatch_modern( + runtime: Any, + method: str, + params: dict[str, Any], + context: RequestContext, +) -> dict[str, Any] | None: + """Handle a request that carries its own protocol version. + + There is no handshake to be missing, so the initialized guard never applies + here. Returns None for a notification. + """ + + if method not in MODERN_METHODS: + raise JsonRpcError(-32601, f"Unknown method: {method}") + if method == "notifications/cancelled": + # Accepted and acknowledged by staying silent, as in the legacy era. + return None + if method == "ping": + return {} + if method == "tools/list": + return runtime.list_tools() + return _call_tool(runtime, params, context) + + +def _dispatch_legacy( + runtime: Any, + request: dict[str, Any], + method: str, + params: dict[str, Any], + context: RequestContext, +) -> dict[str, Any] | None: + """Handle a request that negotiated its version through ``initialize``. + + Handshake state lives on ``runtime.initialized``. A method this server does + not implement is rejected before that state is consulted, so a client + probing for an unsupported method learns the method is unknown instead of + being told to handshake first. Returns None for a notification. + """ + + if method not in KNOWN_METHODS: + raise JsonRpcError(-32601, f"Unknown method: {method}") + if not runtime.initialized and method not in {"initialize", "ping"}: + raise JsonRpcError(-32002, "Server not initialized") + if method == "initialize": + validate_initialize_request(request) + negotiated_version = validate_initialize_params(params) + if runtime.initialized: + # Some connectors send a second initialize on one persistent + # STDIO process. Rejecting it fails their tool scan even though + # the session is healthy, so replay the negotiated handshake + # instead. The initializer is not run again, so no session + # state is reset by a repeat. + if negotiated_version != runtime.protocol_version: + raise JsonRpcError( + -32600, + "Server is already initialized with a different protocol version", + {"expected": runtime.protocol_version, "received": negotiated_version}, + ) + return runtime.initialize_result() + runtime.protocol_version = negotiated_version + client_info = params.get("clientInfo") + result = runtime.initialize(client_info if isinstance(client_info, dict) else None) + runtime.initialized = True + return result + if method == "notifications/initialized": + return None + if method == "notifications/cancelled": + # Accepted and acknowledged by staying silent. A command outlives + # the request that started it and is shared with every other + # client of this workspace, so cancelling a request must not kill + # it; clients terminate a command with kill_command. + return None + if method == "ping": + return {} + if method == "tools/list": + return runtime.list_tools() + if method == "tools/call": + return _call_tool(runtime, params, context) + # only reachable if KNOWN_METHODS gains a method without a branch here + raise JsonRpcError(-32601, f"Unknown method: {method}") + + +def _call_tool(runtime: Any, params: dict[str, Any], context: RequestContext) -> dict[str, Any]: + if not isinstance(params.get("name"), str): + raise JsonRpcError(-32602, "tools/call requires a tool name") + arguments = params.get("arguments") or {} + if not isinstance(arguments, dict): + raise JsonRpcError(-32602, "tools/call arguments must be an object") + return runtime.call_tool(params["name"], arguments, context=context) diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 10468a2..6466101 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -68,12 +68,12 @@ terminate_process_group, ) from .protocol import ( - PROTOCOL_VERSION, - SUPPORTED_PROTOCOL_VERSIONS, + LATEST_LEGACY_PROTOCOL_VERSION, + LEGACY_PROTOCOL_VERSIONS, RequestContext, dispatch_rpc, jsonrpc_error, - protocol_version_is_supported, + legacy_protocol_version_is_supported, response_id, validate_rpc_envelope, ) @@ -1320,7 +1320,7 @@ def __init__( self._runtime_dir_resolved = False self._closed = False self.http_session_id = secrets.token_urlsafe(24) - self.protocol_version = PROTOCOL_VERSION + self.protocol_version = LATEST_LEGACY_PROTOCOL_VERSION self.patch_baselines: dict[str, str | None] = {} self.patch_lock = threading.Lock() self.patch_committer = AtomicPatchCommitter() @@ -4735,7 +4735,7 @@ def server_card_payload(runtime: Runtime, *, oauth_base_url: str | None = None) read_only = [name for name in names if annotations[name].get("readOnlyHint") is True] mutating = [name for name in names if annotations[name].get("readOnlyHint") is not True] payload = { - "protocolVersion": PROTOCOL_VERSION, + "protocolVersion": LATEST_LEGACY_PROTOCOL_VERSION, "server": { "name": SERVER_NAME, "title": SERVER_TITLE, @@ -4894,11 +4894,11 @@ def do_POST(self) -> None: self.send_rpc_error(-32600, "Content-Type must be application/json", status=415) return protocol_version = self.headers.get("MCP-Protocol-Version") - if protocol_version and not protocol_version_is_supported(protocol_version): + if protocol_version and not legacy_protocol_version_is_supported(protocol_version): self.send_rpc_error( -32600, "Unsupported MCP protocol version", - data={"supported": list(SUPPORTED_PROTOCOL_VERSIONS), "received": protocol_version}, + data={"supported": list(LEGACY_PROTOCOL_VERSIONS), "received": protocol_version}, ) return raw_length = self.headers.get("Content-Length") diff --git a/coding_tools_mcp/transport_stdio.py b/coding_tools_mcp/transport_stdio.py index b03a7db..a27b33a 100644 --- a/coding_tools_mcp/transport_stdio.py +++ b/coding_tools_mcp/transport_stdio.py @@ -13,6 +13,8 @@ class StdioRuntime(Protocol): def initialize(self, client_info: dict[str, Any] | None = None) -> dict[str, Any]: ... + def initialize_result(self) -> dict[str, Any]: ... + def list_tools(self) -> dict[str, Any]: ... def call_tool( diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 4aa1343..7f2f2f2 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -31,6 +31,42 @@ from tests.compliance.test_support import ComplianceTestCase +MODERN_PROTOCOL_VERSION = "2026-07-28" +META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion" +META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities" +META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo" + + +def modern_meta( + overrides: dict[str, Any] | None = None, + *, + drop: tuple[str, ...] = (), +) -> dict[str, Any]: + """Build the per-request ``_meta`` a 2026-07-28 client sends.""" + + meta: dict[str, Any] = { + META_PROTOCOL_VERSION: MODERN_PROTOCOL_VERSION, + META_CLIENT_CAPABILITIES: {}, + META_CLIENT_INFO: {"name": "modern-contract-client", "version": "1.0"}, + } + meta.update(overrides or {}) + for key in drop: + meta.pop(key, None) + return meta + + +def modern_request( + request_id: Any, + method: str, + params: dict[str, Any] | None = None, + *, + meta: dict[str, Any] | None = None, +) -> dict[str, Any]: + body = dict(params or {}) + body["_meta"] = modern_meta() if meta is None else meta + return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": body} + + class MCPContractTests(ComplianceTestCase): def test_initialize_succeeds_and_tools_list_is_available(self) -> None: tools = self.client.list_tools() @@ -848,15 +884,6 @@ def test_http_rejects_malformed_json_rpc_envelopes_and_params(self) -> None: ({"id": 1, "method": "ping", "params": {}}, -32600), ({"jsonrpc": "2.0", "id": True, "method": "ping", "params": {}}, -32600), ({"jsonrpc": "2.0", "id": 2, "method": "ping", "params": []}, -32602), - ( - { - "jsonrpc": "2.0", - "id": 3, - "method": "initialize", - "params": {"protocolVersion": "1900-01-01"}, - }, - -32602, - ), ] for payload, code in cases: with self.subTest(payload=payload): @@ -941,20 +968,46 @@ def test_http_rejects_older_protocol_version_header(self) -> None: self.assertEqual(status, 400) self.assertEqual(response.get("error", {}).get("code"), -32600) - def test_initialize_rejects_older_client_protocol(self) -> None: + def test_initialize_downgrades_unsupported_client_protocol(self) -> None: + """A version the server cannot speak is answered with one it can. + + The handshake spec asks the server to name a version of its own rather + than fail, so an older client, a client that guesses, and one that asks + for the stateless protocol (which is carried per request and never + negotiated) all get the newest legacy version back. + """ + + for requested in ("2024-01-01", "1900-01-01", MODERN_PROTOCOL_VERSION, 20260728): + with self.subTest(requested=requested): + response = self.raw_post( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": requested, + "capabilities": {}, + "clientInfo": {"name": "downgrade-sdk", "version": "1.0"}, + }, + } + ) + self.assertNotIn("error", response) + self.assertEqual(response.get("result", {}).get("protocolVersion"), "2025-11-25") + + def test_initialize_echoes_a_supported_client_protocol(self) -> None: response = self.raw_post( { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { - "protocolVersion": "2024-01-01", + "protocolVersion": "2025-06-18", "capabilities": {}, - "clientInfo": {"name": "older-sdk", "version": "1.0"}, + "clientInfo": {"name": "supported-sdk", "version": "1.0"}, }, } ) - self.assertEqual(response.get("error", {}).get("code"), -32602) + self.assertEqual(response.get("result", {}).get("protocolVersion"), "2025-06-18") def test_stdio_transport_uses_newline_delimited_json_rpc_only(self) -> None: process = self.start_stdio_server() @@ -1081,6 +1134,167 @@ def test_stdio_reports_unknown_methods_before_initialize(self) -> None: finally: self.stop_process(process) + def test_stdio_serves_modern_tools_list_without_a_handshake(self) -> None: + process = self.start_stdio_server() + try: + listed = self.stdio_rpc(process, modern_request(1, "tools/list")) + tools = listed.get("result", {}).get("tools") + self.assertIsInstance(tools, list) + self.assertEqual(len(tools), 18) + self.assertTrue({tool.get("name") for tool in tools} >= set(REQUIRED_TOOLS)) + + called = self.stdio_rpc( + process, + modern_request(2, "tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}), + ) + result = called.get("result", {}) + self.assertFalse(result.get("isError", False), result) + structured = result.get("structuredContent") + self.assertIsInstance(structured, dict) + self.assertEqual(structured.get("path"), "src/math.js") + self.assertIn(structured.get("content", ""), self.assert_content_text_is_agent_readable(result)) + finally: + self.stop_process(process) + + def test_stdio_modern_ping_answers_and_cancellation_stays_silent(self) -> None: + process = self.start_stdio_server() + try: + pong = self.stdio_rpc(process, modern_request(1, "ping")) + self.assertNotIn("error", pong) + + self.stdio_send( + process, + { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": "missing", "_meta": modern_meta()}, + }, + ) + self.assert_no_stdio_response(process) + finally: + self.stop_process(process) + + def test_stdio_modern_meta_is_validated_before_the_method_runs(self) -> None: + cases = [ + ( + "non-string version", + modern_meta({META_PROTOCOL_VERSION: 20260728}), + -32602, + ), + ( + "missing clientCapabilities", + modern_meta(drop=(META_CLIENT_CAPABILITIES,)), + -32602, + ), + ( + "non-object clientCapabilities", + modern_meta({META_CLIENT_CAPABILITIES: "tools"}), + -32602, + ), + ( + "non-object clientInfo", + modern_meta({META_CLIENT_INFO: "modern-contract-client"}), + -32602, + ), + ] + process = self.start_stdio_server() + try: + for index, (name, meta, code) in enumerate(cases): + with self.subTest(case=name): + rejected = self.stdio_rpc_allow_error( + process, + modern_request(index + 1, "tools/list", meta=meta), + ) + error = rejected.get("error", {}) + self.assertEqual(error.get("code"), code, rejected) + self.assertNotIn("result", rejected) + + unsupported = self.stdio_rpc_allow_error( + process, + modern_request(99, "tools/list", meta=modern_meta({META_PROTOCOL_VERSION: "2025-11-25"})), + ) + error = unsupported.get("error", {}) + self.assertEqual(error.get("code"), -32022) + # Only modern versions are offered back: naming a legacy version + # here would invite the client to retry it in _meta forever. + self.assertEqual(error.get("data", {}).get("supported"), [MODERN_PROTOCOL_VERSION]) + self.assertEqual(error.get("data", {}).get("received"), "2025-11-25") + + omitted_client_info = self.stdio_rpc( + process, + modern_request(100, "ping", meta=modern_meta(drop=(META_CLIENT_INFO,))), + ) + self.assertNotIn("error", omitted_client_info) + finally: + self.stop_process(process) + + def test_stdio_modern_era_still_reports_unimplemented_methods(self) -> None: + process = self.start_stdio_server() + try: + probe = self.stdio_rpc_allow_error(process, modern_request("discover-probe", "server/discover")) + self.assertEqual(probe.get("error", {}).get("code"), -32601) + self.assertIsNone(process.poll(), "an unsupported probe must not end the stdio session") + finally: + self.stop_process(process) + + def test_stdio_initialize_with_modern_meta_still_negotiates_the_handshake(self) -> None: + process = self.start_stdio_server() + try: + initialize = self.stdio_rpc( + process, + modern_request( + 1, + "initialize", + { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "dual-era-client", "version": "1.0"}, + }, + ), + ) + result = initialize.get("result", {}) + self.assertEqual(result.get("protocolVersion"), "2025-11-25") + self.assertNotIn("resultType", result) + self.assertNotIn("_meta", result) + finally: + self.stop_process(process) + + def test_stdio_legacy_progress_token_is_not_mistaken_for_a_modern_request(self) -> None: + process = self.start_stdio_server() + try: + self.stdio_rpc( + process, + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "progress-token-client", "version": "1.0"}, + }, + }, + ) + called = self.stdio_rpc( + process, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "read_file", + "arguments": {"path": "src/math.js"}, + "_meta": {"progressToken": "token-1"}, + }, + }, + ) + result = called.get("result", {}) + self.assertFalse(result.get("isError", False), result) + for field in ("resultType", "_meta", "ttlMs", "cacheScope"): + self.assertNotIn(field, result) + finally: + self.stop_process(process) + def assert_content_text_is_agent_readable(self, result: dict[str, Any]) -> str: structured = result.get("structuredContent") self.assertIsInstance(structured, dict, f"structuredContent must be an object: {result!r}") From 1bc91e2ae72101c1bf51e64e803e4144c593a4e9 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:03:39 +0000 Subject: [PATCH 11/25] Shape modern results with resultType, serverInfo, and cache hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 2026-07-28 result is expected to declare that it is complete, to name the server that produced it, and to say whether it may be cached. Add one encoder that dispatch_rpc runs over every successful result before the JSON-RPC envelope is built, rather than teaching each handler to decorate its own return value: handlers keep returning business fields, and no field can be added twice or forgotten by the next handler. The encoder branches on era and nothing else. A legacy result is passed straight through, so a handshake client sees exactly the bytes it saw before. A modern result is copied before it is decorated, so the dict the runtime built is never mutated. Only cacheable results get ttlMs and cacheScope, and they go on the result root: a tool definition is a schema clients validate, not a place for cache hints. isError is left alone — a failed tool still answered completely, so resultType stays complete. Server identity comes from the runtime, since protocol.py cannot import the server module, and the handshake now reads it from the same place. Co-authored-by: Cursor --- CHANGELOG.md | 5 ++ coding_tools_mcp/protocol.py | 41 ++++++++++++- coding_tools_mcp/server.py | 19 ++++-- coding_tools_mcp/transport_stdio.py | 2 + tests/compliance/test_mcp_contract.py | 85 ++++++++++++++++++++++++--- 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c41ff6..40363cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,11 @@ Requests without that `_meta` key — including legacy requests that carry `_meta.progressToken`, and every `initialize` — keep the handshake behavior they had. +- Results for `2026-07-28` requests carry `resultType: "complete"` and an + `_meta.io.modelcontextprotocol/serverInfo`, and `tools/list` also carries the + conservative cache hints `ttlMs: 0` and `cacheScope: "private"` on the result + root. Responses to handshake clients are byte-for-byte what they were and + never carry these fields. ### Fixed diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index c83e2d6..24711b5 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -20,6 +20,7 @@ META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion" META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities" META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo" +META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" UNSUPPORTED_PROTOCOL_VERSION = -32022 MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 @@ -42,6 +43,8 @@ "tools/call", } ) +MODERN_CACHEABLE_METHODS = frozenset({"tools/list"}) +MODERN_RESULT_TYPE = "complete" @dataclass(frozen=True) @@ -195,6 +198,38 @@ def modern_request_context(params: Mapping[str, Any]) -> RequestContext: ) +def shape_result( + context: RequestContext, + method: str, + result: dict[str, Any], + server_identity: Mapping[str, Any], +) -> dict[str, Any]: + """Encode one successful result for the era that asked for it. + + Handlers return business fields only; every era-specific field is added + here so no handler can add one twice. A legacy result is returned as it + was built — a client that never spoke the modern protocol must not receive + fields its schema does not know. A modern result is decorated on a shallow + copy so the runtime's own dict is left alone. + """ + + if context.era != MODERN_ERA: + return result + shaped = dict(result) + shaped["resultType"] = MODERN_RESULT_TYPE + carried = shaped.get("_meta") + meta = dict(carried) if isinstance(carried, dict) else {} + meta[META_SERVER_INFO] = dict(server_identity) + shaped["_meta"] = meta + if method in MODERN_CACHEABLE_METHODS: + # A catalog is shaped by the workspace and the permission mode it was + # served under, so the conservative defaults apply: never shared, + # never reused. + shaped["ttlMs"] = 0 + shaped["cacheScope"] = "private" + return shaped + + def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None: """Dispatch one MCP JSON-RPC request against a runtime, shared by all transports. @@ -218,7 +253,11 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None result = _dispatch_legacy(runtime, request, method, params, context) if result is None or request_id is None: return None - return {"jsonrpc": "2.0", "id": request_id, "result": result} + return { + "jsonrpc": "2.0", + "id": request_id, + "result": shape_result(context, method, result, runtime.server_identity()), + } except JsonRpcError as exc: return jsonrpc_error(response_id(request), exc.code, exc.message, exc.data) diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 6466101..776c73f 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -1467,14 +1467,23 @@ def initialize_result(self) -> dict[str, Any]: return { "protocolVersion": self.protocol_version, "capabilities": {"tools": {"listChanged": False}}, - "serverInfo": { - "name": SERVER_NAME, - "title": SERVER_TITLE, - "version": __version__, - }, + "serverInfo": self.server_identity(), "instructions": self.project_context.server_instructions(), } + def server_identity(self) -> dict[str, Any]: + """Name this server for the handshake and for modern result metadata. + + The protocol layer cannot import this module, so it reads the identity + through the runtime it is already dispatching against. + """ + + return { + "name": SERVER_NAME, + "title": SERVER_TITLE, + "version": __version__, + } + def list_tools(self) -> dict[str, Any]: return { "tools": [ diff --git a/coding_tools_mcp/transport_stdio.py b/coding_tools_mcp/transport_stdio.py index a27b33a..de80f94 100644 --- a/coding_tools_mcp/transport_stdio.py +++ b/coding_tools_mcp/transport_stdio.py @@ -15,6 +15,8 @@ def initialize(self, client_info: dict[str, Any] | None = None) -> dict[str, Any def initialize_result(self) -> dict[str, Any]: ... + def server_identity(self) -> dict[str, Any]: ... + def list_tools(self) -> dict[str, Any]: ... def call_tool( diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 7f2f2f2..7fd2e7e 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -14,9 +14,12 @@ import urllib.parse import urllib.request from collections.abc import Callable +from pathlib import Path from typing import Any +from coding_tools_mcp import __version__ from coding_tools_mcp.server import MAX_HTTP_REQUEST_BYTES +from tests.compliance.fixtures import workspace_from_fixture from tests.compliance.mcp_client import ( FORBIDDEN_TOOL_NAMES, FORBIDDEN_TOOL_TERMS, @@ -35,6 +38,7 @@ META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion" META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities" META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo" +META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" def modern_meta( @@ -1138,21 +1142,51 @@ def test_stdio_serves_modern_tools_list_without_a_handshake(self) -> None: process = self.start_stdio_server() try: listed = self.stdio_rpc(process, modern_request(1, "tools/list")) - tools = listed.get("result", {}).get("tools") + result = listed.get("result", {}) + self.assert_modern_result(result) + self.assertEqual(result.get("ttlMs"), 0) + self.assertEqual(result.get("cacheScope"), "private") + + tools = result.get("tools") self.assertIsInstance(tools, list) self.assertEqual(len(tools), 18) self.assertTrue({tool.get("name") for tool in tools} >= set(REQUIRED_TOOLS)) + for tool in tools: + # The cache hints describe the catalog, not the entries in it; + # a tool definition is a schema clients validate against. + self.assertNotIn("ttlMs", tool, tool) + self.assertNotIn("cacheScope", tool, tool) + self.assertNotIn("ttlMs", tool.get("outputSchema", {}), tool) + self.assertNotIn("cacheScope", tool.get("outputSchema", {}), tool) + finally: + self.stop_process(process) + def test_stdio_modern_tools_call_shapes_success_and_tool_failure(self) -> None: + process = self.start_stdio_server() + try: called = self.stdio_rpc( process, - modern_request(2, "tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}), + modern_request(1, "tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}), ) result = called.get("result", {}) + self.assert_modern_result(result) + self.assertNotIn("ttlMs", result) + self.assertNotIn("cacheScope", result) self.assertFalse(result.get("isError", False), result) structured = result.get("structuredContent") self.assertIsInstance(structured, dict) self.assertEqual(structured.get("path"), "src/math.js") self.assertIn(structured.get("content", ""), self.assert_content_text_is_agent_readable(result)) + + failed = self.stdio_rpc( + process, + modern_request(2, "tools/call", {"name": "read_file", "arguments": {"path": "no/such/file.js"}}), + ) + failure = failed.get("result", {}) + self.assertTrue(failure.get("isError"), failure) + # resultType describes the result envelope; isError is a + # tools-domain verdict. A failed tool still answered completely. + self.assert_modern_result(failure) finally: self.stop_process(process) @@ -1160,7 +1194,10 @@ def test_stdio_modern_ping_answers_and_cancellation_stays_silent(self) -> None: process = self.start_stdio_server() try: pong = self.stdio_rpc(process, modern_request(1, "ping")) - self.assertNotIn("error", pong) + result = pong.get("result", {}) + self.assert_modern_result(result) + self.assertNotIn("ttlMs", result) + self.assertNotIn("cacheScope", result) self.stdio_send( process, @@ -1224,7 +1261,7 @@ def test_stdio_modern_meta_is_validated_before_the_method_runs(self) -> None: process, modern_request(100, "ping", meta=modern_meta(drop=(META_CLIENT_INFO,))), ) - self.assertNotIn("error", omitted_client_info) + self.assert_modern_result(omitted_client_info.get("result", {})) finally: self.stop_process(process) @@ -1295,6 +1332,39 @@ def test_stdio_legacy_progress_token_is_not_mistaken_for_a_modern_request(self) finally: self.stop_process(process) + def test_stdio_modern_image_result_carries_the_encoded_image_once(self) -> None: + with workspace_from_fixture("image-project") as workspace: + process = self.start_stdio_server(workspace=workspace.root) + try: + viewed = self.stdio_rpc( + process, + modern_request( + 1, + "tools/call", + {"name": "view_image", "arguments": {"path": "assets/screenshot.png"}}, + ), + ) + result = viewed.get("result", {}) + self.assert_modern_result(result) + blocks = [item for item in result.get("content", []) if item.get("type") == "image"] + self.assertEqual(len(blocks), 1) + encoded = blocks[0].get("data") + self.assertIsInstance(encoded, str) + # The result metadata names the server, never echoes payloads. + self.assertEqual(json.dumps(result).count(str(encoded)), 1) + finally: + self.stop_process(process) + + def assert_modern_result(self, result: dict[str, Any]) -> None: + self.assertEqual(result.get("resultType"), "complete", result) + meta = result.get("_meta") + self.assertIsInstance(meta, dict, result) + self.assertEqual( + meta.get(META_SERVER_INFO), + {"name": "coding-tools-mcp", "title": "Coding Tools MCP", "version": __version__}, + result, + ) + def assert_content_text_is_agent_readable(self, result: dict[str, Any]) -> str: structured = result.get("structuredContent") self.assertIsInstance(structured, dict, f"structuredContent must be an object: {result!r}") @@ -1304,17 +1374,18 @@ def assert_content_text_is_agent_readable(self, result: dict[str, Any]) -> str: self.assertTrue(text_items, f"content must include agent-readable text: {result!r}") return "\n".join(str(item) for item in text_items) - def start_stdio_server(self) -> subprocess.Popen[str]: + def start_stdio_server(self, workspace: Path | None = None) -> subprocess.Popen[str]: + root = workspace or self.workspace.root return subprocess.Popen( [ sys.executable, "-m", "coding_tools_mcp", "--workspace", - str(self.workspace.root), + str(root), "--stdio", ], - cwd=str(self.workspace.root), + cwd=str(root), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, From a77dd1d58abedf36577b3882b062fdb10c06d282 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:13:48 +0000 Subject: [PATCH 12/25] Remove HTTP sessions and serve every request from one workspace runtime Sessions bought nothing once commands and the default cwd stopped being session state: the id was a MAY the client only echoed back, while the 128-session ceiling, the idle expiry, and the handshake admission gate were all failure modes of our own making. HTTP now answers every request from the runtime that owns the workspace, ignores any Mcp-Session-Id a client returns from an older server, and refuses DELETE with 405. With no session to attach it to, the negotiated protocol version becomes a per-request value: initialize negotiates one and answers with it as often as it is asked, which is what a connector that probes, falls back, and handshakes again needs, and telemetry still records one session per process. server_info and the server card report the versions the server speaks instead of the one a session had agreed on. Co-authored-by: Cursor --- CHANGELOG.md | 25 ++++- benchmarks/mcp_http.py | 10 -- coding_tools_mcp/protocol.py | 55 +++-------- coding_tools_mcp/server.py | 133 ++++++++------------------ coding_tools_mcp/transport_http.py | 96 ------------------- coding_tools_mcp/transport_stdio.py | 11 ++- tests/compliance/mcp_client.py | 6 -- tests/compliance/test_mcp_contract.py | 127 ++++++++++++++---------- 8 files changed, 162 insertions(+), 301 deletions(-) delete mode 100644 coding_tools_mcp/transport_http.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40363cb..b979a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,8 +56,29 @@ - A method this server does not implement now returns `-32601` before the handshake as well as after it. Such a call previously returned `-32002 Server not initialized`, which tells a client to handshake and retry a method that - will never exist. Implemented methods are unchanged: calling one before - `initialize` still returns `-32002`. + will never exist. +- **Breaking:** HTTP is now stateless. The server no longer issues an + `Mcp-Session-Id`, and a request that returns one from an older server is + served normally instead of being refused with `-32001 Unknown MCP session`. + Every request is answered by the one runtime that owns the workspace, so the + 128-session ceiling and its `503`, the session idle expiry, and the + `MCP-Protocol-Version`-must-match-the-session check are all gone. +- **Breaking:** `DELETE /mcp` now returns `405` with `Allow: POST`; there is no + session to terminate. `DELETE` is no longer advertised in the `Allow`, + `Access-Control-Allow-Methods`, or server card `transport.methods` lists. +- **Breaking:** the handshake is no longer an admission gate. `tools/list`, + `tools/call`, and the other implemented methods are served whether or not + the caller sent `initialize` first, and `-32002 Server not initialized` is + never returned. `initialize` is now idempotent: each one negotiates a + version on its own and answers with it, so a repeat that names a different + supported version is answered with that version instead of `-32600 Server is + already initialized with a different protocol version`. Only the first + handshake of a process records a telemetry session. +- **Breaking:** `server_info` reports `supported_protocol_versions` (every + version this server speaks, newest first) in place of `protocol_version` + (the one version a session had negotiated), and the server card at + `/.well-known/mcp.json` reports `supportedProtocolVersions` in place of + `protocolVersion`. Neither is a session-scoped value any more. - Runtime state a shared server exposes to concurrent requests is hardened: the runtime directory (and the `HOME`, `TMPDIR`, and cache directories under it) is resolved to the primary or fallback location exactly once, so a later diff --git a/benchmarks/mcp_http.py b/benchmarks/mcp_http.py index a339764..32c2f24 100644 --- a/benchmarks/mcp_http.py +++ b/benchmarks/mcp_http.py @@ -76,7 +76,6 @@ def __init__( self.timeout = timeout self.protocol_version = protocol_version self._next_id = 1 - self.session_id: str | None = None def initialize(self) -> dict[str, Any]: result = self.request( @@ -143,8 +142,6 @@ def _post(self, payload: dict[str, Any], *, expect_reply: bool) -> JsonRpcReply: "Content-Type": "application/json", "MCP-Protocol-Version": self.protocol_version, } - if self.session_id: - headers["Mcp-Session-Id"] = self.session_id request = urllib.request.Request(self.endpoint, data=body, headers=headers, method="POST") try: @@ -152,11 +149,9 @@ def _post(self, payload: dict[str, Any], *, expect_reply: bool) -> JsonRpcReply: status = response.getcode() raw = response.read() response_headers = {k: v for k, v in response.headers.items()} - self._capture_session_id(response_headers) except urllib.error.HTTPError as exc: raw = exc.read() response_headers = {k: v for k, v in exc.headers.items()} - self._capture_session_id(response_headers) parsed = self._parse_body(raw, response_headers.get("Content-Type", "")) raise McpHttpError( f"HTTP {exc.code} from MCP endpoint", @@ -177,11 +172,6 @@ def _post(self, payload: dict[str, Any], *, expect_reply: bool) -> JsonRpcReply: ) return JsonRpcReply(status=status, payload=parsed, headers=response_headers) - def _capture_session_id(self, headers: dict[str, str]) -> None: - for key, value in headers.items(): - if key.lower() == "mcp-session-id" and value: - self.session_id = value - def _parse_body(self, raw: bytes, content_type: str) -> dict[str, Any] | None: text = raw.decode("utf-8", errors="replace").strip() if not text: diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index 24711b5..e6baebb 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -25,16 +25,6 @@ UNSUPPORTED_PROTOCOL_VERSION = -32022 MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 -KNOWN_METHODS = frozenset( - { - "initialize", - "notifications/initialized", - "notifications/cancelled", - "ping", - "tools/list", - "tools/call", - } -) MODERN_METHODS = frozenset( { "notifications/cancelled", @@ -234,9 +224,10 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None """Dispatch one MCP JSON-RPC request against a runtime, shared by all transports. The era is decided first, from the request itself: a modern request states - its protocol version per request and never touches the handshake state a - legacy client builds up on ``runtime.initialized``. Transports add only - their transport-specific framing (session headers, stream handling) around + its protocol version per request, a legacy one negotiated it through a + handshake this runtime keeps no record of. Neither era leaves state behind, + so one runtime answers every client of the workspace. Transports add only + their transport-specific framing (stream handling, status codes) around this. Returns None for notifications and requests without an id. """ @@ -249,7 +240,7 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None context = modern_request_context(params) result = _dispatch_modern(runtime, method, params, context) else: - context = RequestContext(era=LEGACY_ERA, protocol_version=runtime.protocol_version) + context = RequestContext(era=LEGACY_ERA, protocol_version=LATEST_LEGACY_PROTOCOL_VERSION) result = _dispatch_legacy(runtime, request, method, params, context) if result is None or request_id is None: return None @@ -295,37 +286,22 @@ def _dispatch_legacy( ) -> dict[str, Any] | None: """Handle a request that negotiated its version through ``initialize``. - Handshake state lives on ``runtime.initialized``. A method this server does - not implement is rejected before that state is consulted, so a client - probing for an unsupported method learns the method is unknown instead of - being told to handshake first. Returns None for a notification. + No handshake state is kept, so nothing here depends on what a client sent + before: a method this server does not implement is unknown, and every other + method is served whether or not the client handshook first. ``initialize`` + is therefore idempotent — it negotiates a version and answers with it as + often as it is asked, which is what a connector that probes, falls back, + and handshakes again needs. Returns None for a notification. """ - if method not in KNOWN_METHODS: - raise JsonRpcError(-32601, f"Unknown method: {method}") - if not runtime.initialized and method not in {"initialize", "ping"}: - raise JsonRpcError(-32002, "Server not initialized") if method == "initialize": validate_initialize_request(request) negotiated_version = validate_initialize_params(params) - if runtime.initialized: - # Some connectors send a second initialize on one persistent - # STDIO process. Rejecting it fails their tool scan even though - # the session is healthy, so replay the negotiated handshake - # instead. The initializer is not run again, so no session - # state is reset by a repeat. - if negotiated_version != runtime.protocol_version: - raise JsonRpcError( - -32600, - "Server is already initialized with a different protocol version", - {"expected": runtime.protocol_version, "received": negotiated_version}, - ) - return runtime.initialize_result() - runtime.protocol_version = negotiated_version client_info = params.get("clientInfo") - result = runtime.initialize(client_info if isinstance(client_info, dict) else None) - runtime.initialized = True - return result + return runtime.initialize( + client_info if isinstance(client_info, dict) else None, + negotiated_version, + ) if method == "notifications/initialized": return None if method == "notifications/cancelled": @@ -340,7 +316,6 @@ def _dispatch_legacy( return runtime.list_tools() if method == "tools/call": return _call_tool(runtime, params, context) - # only reachable if KNOWN_METHODS gains a method without a branch here raise JsonRpcError(-32601, f"Unknown method: {method}") diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 776c73f..09c1d6f 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -70,6 +70,7 @@ from .protocol import ( LATEST_LEGACY_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSIONS, + MODERN_PROTOCOL_VERSIONS, RequestContext, dispatch_rpc, jsonrpc_error, @@ -81,7 +82,6 @@ from .telemetry import SessionTelemetry from .textutils import DEFAULT_MAX_LINES, TextTruncation, truncate_text_head from .tool_results import make_tool_result -from .transport_http import HTTPSessionManager from .transport_stdio import serve_stdio @@ -1319,18 +1319,15 @@ def __init__( self._runtime_dir_lock = threading.Lock() self._runtime_dir_resolved = False self._closed = False - self.http_session_id = secrets.token_urlsafe(24) - self.protocol_version = LATEST_LEGACY_PROTOCOL_VERSION self.patch_baselines: dict[str, str | None] = {} self.patch_lock = threading.Lock() self.patch_committer = AtomicPatchCommitter() # ProjectContext is frozen and derived only from the workspace tree, so - # per-session HTTP runtimes reuse the server's copy instead of re-running - # discovery (git ls-files / directory walk) on every connect. + # an embedder that builds several runtimes over one workspace can reuse + # the discovery (git ls-files / directory walk) result. self.project_context: ProjectContext = ( project_context if project_context is not None else load_project_context(self.workspace.root) ) - self.initialized = False self.telemetry = SessionTelemetry(permission_mode=self.permission_mode, transport=transport) self._tool_handlers = {name: getattr(self, name) for name in TOOL_REGISTRY} @@ -1453,19 +1450,24 @@ def is_allowed_command_tmp_path(self, candidate: str) -> bool: return False return is_relative_to(resolved, self.runtime_dir) - def initialize(self, client_info: dict[str, Any] | None = None) -> dict[str, Any]: - self.telemetry.record_session_start(client_info, self.protocol_version) - return self.initialize_result() + def initialize( + self, + client_info: dict[str, Any] | None = None, + protocol_version: str = LATEST_LEGACY_PROTOCOL_VERSION, + ) -> dict[str, Any]: + self.telemetry.record_session_start(client_info, protocol_version) + return self.initialize_result(protocol_version) - def initialize_result(self) -> dict[str, Any]: - """Build the handshake payload without recording a new session. + def initialize_result(self, protocol_version: str = LATEST_LEGACY_PROTOCOL_VERSION) -> dict[str, Any]: + """Build the handshake payload for one negotiated version. - Replaying a duplicate initialize uses this so the telemetry session - count stays tied to real sessions. + The version is negotiated per request rather than stored: one runtime + serves every client of the workspace, and two of them may well have + handshaken on different versions. """ return { - "protocolVersion": self.protocol_version, + "protocolVersion": protocol_version, "capabilities": {"tools": {"listChanged": False}}, "serverInfo": self.server_identity(), "instructions": self.project_context.server_instructions(), @@ -1534,7 +1536,7 @@ def server_info_payload(self) -> dict[str, Any]: "server": SERVER_NAME, "title": SERVER_TITLE, "version": __version__, - "protocol_version": self.protocol_version, + "supported_protocol_versions": [*MODERN_PROTOCOL_VERSIONS, *LEGACY_PROTOCOL_VERSIONS], **self._exec_environment_summary(), "auth_enabled": self.auth_enabled(), "dangerously_skip_all_permissions": self.dangerously_skip_all_permissions, @@ -4744,7 +4746,7 @@ def server_card_payload(runtime: Runtime, *, oauth_base_url: str | None = None) read_only = [name for name in names if annotations[name].get("readOnlyHint") is True] mutating = [name for name in names if annotations[name].get("readOnlyHint") is not True] payload = { - "protocolVersion": LATEST_LEGACY_PROTOCOL_VERSION, + "supportedProtocolVersions": [*MODERN_PROTOCOL_VERSIONS, *LEGACY_PROTOCOL_VERSIONS], "server": { "name": SERVER_NAME, "title": SERVER_TITLE, @@ -4753,7 +4755,7 @@ def server_card_payload(runtime: Runtime, *, oauth_base_url: str | None = None) "transport": { "type": "streamable_http", "endpoint": MCP_ENDPOINT_PATH, - "methods": ["POST", "DELETE", "OPTIONS"], + "methods": ["POST", "OPTIONS"], }, "auth": _server_card_auth(runtime, oauth_base_url=oauth_base_url), "tools": { @@ -4775,7 +4777,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler): @property def runtime(self) -> Runtime: - return cast(Runtime, getattr(self, "_runtime", self.server.control_runtime)) # type: ignore[attr-defined] + return cast(Runtime, self.server.runtime) # type: ignore[attr-defined] def log_message(self, format: str, *args: Any) -> None: print(format % args, file=sys.stderr) @@ -4812,14 +4814,14 @@ def do_DELETE(self) -> None: if not self.is_authorized(): self.send_unauthorized() return - session_id = self.headers.get("Mcp-Session-Id") - if not session_id or not self.server.sessions.delete(session_id): # type: ignore[attr-defined] - self.send_rpc_error(-32001, "Unknown MCP session", status=404) - return - self.send_response(200) - self.send_header("Content-Length", "0") - self.send_cors_headers() - self.end_headers() + # There is no session to terminate: every request is served by the one + # workspace runtime, which outlives any single client. + self.send_rpc_error( + -32601, + "DELETE is not supported: this endpoint has no sessions to terminate", + status=405, + extra_headers={"Allow": "POST"}, + ) def do_OPTIONS(self) -> None: request_path = self.path.split("?", 1)[0] @@ -4840,7 +4842,7 @@ def do_OPTIONS(self) -> None: self.send_json({"error": "Origin denied"}, status=403) return self.send_response(204) - self.send_header("Allow", "GET, HEAD, POST, DELETE, OPTIONS") + self.send_header("Allow", "GET, HEAD, POST, OPTIONS") self.send_cors_headers() self.end_headers() @@ -4868,7 +4870,7 @@ def handle_metadata_request(self, *, head_only: bool) -> None: -32000, "SSE GET stream is not supported", status=405, - extra_headers={"Allow": "POST, DELETE"}, + extra_headers={"Allow": "POST"}, head_only=head_only, ) return @@ -4950,52 +4952,13 @@ def do_POST(self) -> None: exc.code, exc.message, status=200, request_id=response_id(request), data=exc.data ) return - method = request.get("method") - session_id = self.headers.get("Mcp-Session-Id") - created_session = False - if method == "initialize": - if session_id: - self.send_rpc_error( - -32600, "initialize must not include Mcp-Session-Id", request_id=request.get("id") - ) - return - try: - self._runtime = self.server.sessions.create() # type: ignore[attr-defined] - except RuntimeError as exc: - self.send_rpc_error(-32000, str(exc), status=503, request_id=request.get("id")) - return - self._send_session_header = True - created_session = True - elif session_id: - runtime = self.server.sessions.get(session_id) # type: ignore[attr-defined] - if runtime is None: - self.send_rpc_error( - -32001, "Unknown MCP session", status=404, request_id=response_id(request) - ) - return - self._runtime = runtime - self._send_session_header = True - if protocol_version != runtime.protocol_version: - self.send_rpc_error( - -32600, - "MCP-Protocol-Version does not match the initialized session", - request_id=request.get("id"), - data={"expected": runtime.protocol_version, "received": protocol_version}, - ) - return - elif method == "ping": - self._runtime = self.server.control_runtime # type: ignore[attr-defined] - else: - self.send_rpc_error(-32002, "Server not initialized", request_id=request.get("id")) - return + # Every request is served by the one workspace runtime. A client that + # still echoes an ``Mcp-Session-Id`` from an older server is served + # like any other rather than rejected, so an upgrade needs no client + # change. response = self.handle_rpc(request) - if created_session and response is not None and "error" in response: - self.server.sessions.delete(self.runtime.http_session_id) # type: ignore[attr-defined] - self._send_session_header = False if response is None: self.send_response(202) - if getattr(self, "_send_session_header", False): - self.send_header("Mcp-Session-Id", self.runtime.http_session_id) self.send_cors_headers() self.end_headers() return @@ -5367,7 +5330,7 @@ def send_cors_headers(self) -> None: if origin and is_allowed_origin(origin): self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Vary", "Origin") - self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS") self.send_header( "Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id", @@ -5386,8 +5349,6 @@ def send_json( self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") - if getattr(self, "_send_session_header", False): - self.send_header("Mcp-Session-Id", self.runtime.http_session_id) self.send_cors_headers() for name, value in (extra_headers or {}).items(): self.send_header(name, value) @@ -5403,16 +5364,13 @@ def __init__( self, address: tuple[str, int], handler: type[MCPHandler], - control_runtime: Runtime, - runtime_factory: Any, + runtime: Runtime, ) -> None: super().__init__(address, handler) - self.control_runtime = control_runtime - self.sessions = HTTPSessionManager(runtime_factory) + self.runtime = runtime def server_close(self) -> None: - self.sessions.close() - self.control_runtime.close() + self.runtime.close() super().server_close() @@ -5567,20 +5525,7 @@ def run_http(args: argparse.Namespace) -> int: return 2 runtime = build_runtime(args, runtime_policy, auth_token=auth_token, oauth_config=oauth_config, transport="http") - - def runtime_factory() -> Runtime: - return build_runtime( - args, - runtime_policy, - auth_token=auth_token, - oauth_config=oauth_config, - emit_warning=False, - project_context=runtime.project_context, - transport="http", - command_manager=runtime.command_manager, - ) - - server = RuntimeHTTPServer((args.host, args.port), MCPHandler, runtime, runtime_factory) + server = RuntimeHTTPServer((args.host, args.port), MCPHandler, runtime) if oauth_config: url_label = oauth_config.server_url or "dynamic request URL" suffix = " + bearer" if runtime.auth_token else "" diff --git a/coding_tools_mcp/transport_http.py b/coding_tools_mcp/transport_http.py deleted file mode 100644 index 698d082..0000000 --- a/coding_tools_mcp/transport_http.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -import threading -import time -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - - -MAX_HTTP_SESSIONS = 128 -HTTP_SESSION_TTL_SECONDS = 60 * 60 - - -def _close_runtime(runtime: Any) -> None: - close = getattr(runtime, "close", None) - if callable(close): - close() - - -@dataclass -class HTTPSessionRecord: - runtime: Any - last_seen: float - - -class HTTPSessionManager: - """Own independent Runtime instances for Streamable HTTP sessions.""" - - def __init__(self, factory: Callable[[], Any]) -> None: - self._factory = factory - self._sessions: dict[str, HTTPSessionRecord] = {} - self._lock = threading.Lock() - self._creating = 0 - self._closed = False - - def create(self) -> Any: - self.prune() - with self._lock: - if self._closed: - raise RuntimeError("HTTP session manager is closed") - if len(self._sessions) + self._creating >= MAX_HTTP_SESSIONS: - raise RuntimeError("maximum HTTP session count reached") - self._creating += 1 - runtime: Any | None = None - installed = False - try: - runtime = self._factory() - record = HTTPSessionRecord(runtime=runtime, last_seen=time.time()) - with self._lock: - if self._closed: - raise RuntimeError("HTTP session manager is closed") - if runtime.http_session_id in self._sessions: - raise RuntimeError("duplicate HTTP session identifier") - self._sessions[runtime.http_session_id] = record - installed = True - return runtime - finally: - with self._lock: - self._creating -= 1 - if runtime is not None and not installed: - _close_runtime(runtime) - - def get(self, session_id: str) -> Any | None: - self.prune() - with self._lock: - if self._closed: - return None - record = self._sessions.get(session_id) - if record is None: - return None - record.last_seen = time.time() - return record.runtime - - def delete(self, session_id: str) -> bool: - with self._lock: - record = self._sessions.pop(session_id, None) - if record is None: - return False - _close_runtime(record.runtime) - return True - - def prune(self) -> None: - cutoff = time.time() - HTTP_SESSION_TTL_SECONDS - with self._lock: - expired = [session_id for session_id, record in self._sessions.items() if record.last_seen < cutoff] - records = [self._sessions.pop(session_id) for session_id in expired] - for record in records: - _close_runtime(record.runtime) - - def close(self) -> None: - with self._lock: - self._closed = True - records = list(self._sessions.values()) - self._sessions.clear() - for record in records: - _close_runtime(record.runtime) diff --git a/coding_tools_mcp/transport_stdio.py b/coding_tools_mcp/transport_stdio.py index de80f94..b35d74c 100644 --- a/coding_tools_mcp/transport_stdio.py +++ b/coding_tools_mcp/transport_stdio.py @@ -8,12 +8,13 @@ class StdioRuntime(Protocol): - protocol_version: str - initialized: bool - - def initialize(self, client_info: dict[str, Any] | None = None) -> dict[str, Any]: ... + def initialize( + self, + client_info: dict[str, Any] | None = None, + protocol_version: str = ..., + ) -> dict[str, Any]: ... - def initialize_result(self) -> dict[str, Any]: ... + def initialize_result(self, protocol_version: str = ...) -> dict[str, Any]: ... def server_identity(self) -> dict[str, Any]: ... diff --git a/tests/compliance/mcp_client.py b/tests/compliance/mcp_client.py index c1089e9..b55ee10 100644 --- a/tests/compliance/mcp_client.py +++ b/tests/compliance/mcp_client.py @@ -103,7 +103,6 @@ class MCPClient: workspace: Path url: str | None = None process: subprocess.Popen[str] | None = None - session_id: str | None = None request_id: int = 0 initialized: bool = False @@ -257,15 +256,10 @@ def _post(self, payload: dict[str, Any]) -> dict[str, Any]: auth_token = os.environ.get("CODING_TOOLS_MCP_AUTH_TOKEN") if auth_token: headers["Authorization"] = f"Bearer {auth_token}" - if self.session_id: - headers["Mcp-Session-Id"] = self.session_id request = urllib.request.Request(self.url, data=data, headers=headers, method="POST") try: request_timeout = float(os.environ.get("CODING_TOOLS_MCP_CLIENT_TIMEOUT", "30")) with urllib.request.urlopen(request, timeout=request_timeout) as response: - session_id = response.headers.get("Mcp-Session-Id") - if session_id: - self.session_id = session_id body = response.read() if response.status in (202, 204) or not body: return {} diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 7fd2e7e..9ac02ba 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -132,7 +132,7 @@ def test_high_confusion_tools_include_model_ready_examples(self) -> None: with self.subTest(tool=name, fragment=fragment): self.assertIn(fragment, description) - def test_http_sessions_share_workspace_commands(self) -> None: + def test_concurrent_http_clients_share_workspace_commands(self) -> None: with MCPClient(self.workspace.root, url=self.client.url) as sibling: started = self.client.call_tool( "exec_command", @@ -150,7 +150,7 @@ def test_http_sessions_share_workspace_commands(self) -> None: ) self.assertIn(killed.get("status"), {"killed", "exited"}) - def test_http_session_delete_does_not_terminate_workspace_command(self) -> None: + def test_http_client_disconnect_does_not_terminate_workspace_command(self) -> None: with MCPClient(self.workspace.root, url=self.client.url) as owner: started = self.assert_tool_success( owner.call_tool( @@ -406,44 +406,55 @@ def test_http_origin_policy_requires_exact_loopback_host(self) -> None: self.assertEqual(response.get("error", {}).get("code"), -32600) self.assertIn("Origin denied", response.get("error", {}).get("message", "")) - def test_http_rejects_unknown_session_id_header(self) -> None: - self.assertIsNotNone(self.client.session_id) - body = b'{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}' - accepted_status, accepted = self.raw_http_post(body, headers={"Mcp-Session-Id": str(self.client.session_id)}) - self.assertEqual(accepted_status, 200) - self.assertEqual(accepted.get("result"), {}) + def test_http_ignores_any_session_id_header(self) -> None: + """A client that still echoes a session id from an older server is served. + + The header was only ever sent because a server issued one; this server + issues none, so whatever a client returns is neither trusted nor a + reason to refuse the request. + """ - rejected_status, rejected = self.raw_http_post(body, headers={"Mcp-Session-Id": "not-the-current-session"}) - self.assertEqual(rejected_status, 404) - self.assertEqual(rejected.get("id"), 1) - self.assertEqual(rejected.get("error", {}).get("code"), -32001) - self.assertIn("Unknown MCP session", rejected.get("error", {}).get("message", "")) + body = b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' + for session_id in ("not-the-current-session", "", "..stale-handle.."): + with self.subTest(session_id=session_id): + status, response = self.raw_http_post(body, headers={"Mcp-Session-Id": session_id}) + self.assertEqual(status, 200) + self.assertNotIn("error", response) + self.assertIsInstance(response.get("result", {}).get("tools"), list) - def test_http_delete_terminates_only_the_selected_session(self) -> None: + def test_http_delete_is_rejected_without_disturbing_running_commands(self) -> None: self.assertIsNotNone(self.client.url) - with MCPClient(self.workspace.root, url=self.client.url) as sibling: - sibling_session = str(sibling.session_id) - parsed = urllib.parse.urlparse(str(self.client.url)) - base = f"{parsed.scheme}://{parsed.netloc}" - status, _, body = self.raw_base_http_request( - base, - "DELETE", - parsed.path or "/mcp", - headers={ - "Mcp-Session-Id": sibling_session, - "MCP-Protocol-Version": "2025-06-18", - }, - ) - self.assertEqual(status, 200, body) - rejected_status, rejected = self.raw_http_post( - b'{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}', - headers={"Mcp-Session-Id": sibling_session}, + started = self.assert_tool_success( + self.client.call_tool( + "exec_command", + {"cmd": "sleep 5", "timeout_ms": 10000, "yield_time_ms": 0}, ) - self.assertEqual(rejected_status, 404) - self.assertEqual(rejected.get("error", {}).get("code"), -32001) + ) + command_id = started.get("command_id") + self.assertIsInstance(command_id, str) - still_alive = self.client.rpc("ping", {}) - self.assertEqual(still_alive, {}) + parsed = urllib.parse.urlparse(str(self.client.url)) + base = f"{parsed.scheme}://{parsed.netloc}" + status, headers, body = self.raw_base_http_request( + base, + "DELETE", + parsed.path or "/mcp", + headers={"MCP-Protocol-Version": "2025-06-18"}, + ) + self.assertEqual(status, 405, body) + self.assertEqual(headers.get("allow"), "POST") + + unknown_status, _, _ = self.raw_base_http_request(base, "DELETE", "/not-mcp") + self.assertEqual(unknown_status, 404) + + polled = self.assert_tool_success( + self.client.call_tool("write_stdin", {"command_id": command_id, "chars": "", "yield_time_ms": 0}) + ) + self.assertEqual(polled.get("status"), "running") + killed = self.assert_tool_success( + self.client.call_tool("kill_command", {"command_id": command_id, "signal": "KILL"}) + ) + self.assertIn(killed.get("status"), {"killed", "exited"}) def test_http_discovery_endpoints_return_server_card_metadata(self) -> None: self.assertIsNotNone(self.client.url) @@ -454,9 +465,14 @@ def test_http_discovery_endpoints_return_server_card_metadata(self) -> None: request = urllib.request.Request(base + path, method="GET") with urllib.request.urlopen(request, timeout=5) as response: body = json.loads(response.read().decode("utf-8")) - self.assertEqual(body.get("protocolVersion"), "2025-11-25") + self.assertEqual( + body.get("supportedProtocolVersions"), + [MODERN_PROTOCOL_VERSION, "2025-11-25", "2025-06-18"], + ) + self.assertNotIn("protocolVersion", body) self.assertEqual(body.get("server", {}).get("name"), "coding-tools-mcp") self.assertEqual(body.get("transport", {}).get("endpoint"), "/mcp") + self.assertEqual(body.get("transport", {}).get("methods"), ["POST", "OPTIONS"]) self.assertEqual(body.get("auth", {}).get("type"), "none") self.assertIn("tools", body) @@ -900,15 +916,15 @@ def test_http_rejects_malformed_json_rpc_envelopes_and_params(self) -> None: self.assertIsNone(response.get("id")) self.assertEqual(response.get("error", {}).get("code"), -32700) - def test_http_rejects_tools_before_initialize(self) -> None: + def test_http_serves_tools_without_a_handshake(self) -> None: process, url = self.start_raw_http_server() try: self.wait_for_ping(url) - with self.assertRaises(urllib.error.HTTPError) as raised: - self.raw_post_to(url, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) - response = json.loads(raised.exception.read().decode("utf-8")) - self.assertEqual(response.get("error", {}).get("code"), -32002) - self.assertIn("not initialized", response.get("error", {}).get("message", "").lower()) + response = self.raw_post_to(url, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) + self.assertNotIn("error", response) + tools = response.get("result", {}).get("tools") + self.assertIsInstance(tools, list) + self.assertTrue({tool.get("name") for tool in tools} >= set(REQUIRED_TOOLS)) finally: self.stop_process(process) @@ -1057,14 +1073,16 @@ def test_stdio_transport_uses_newline_delimited_json_rpc_only(self) -> None: finally: self.stop_process(process) - def test_stdio_rejects_preinitialize_calls_and_accepts_cancel_notification(self) -> None: + def test_stdio_serves_preinitialize_calls_and_accepts_cancel_notification(self) -> None: process = self.start_stdio_server() try: - rejected = self.stdio_rpc_allow_error( + listed = self.stdio_rpc( process, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, ) - self.assertEqual(rejected.get("error", {}).get("code"), -32002) + tools = listed.get("result", {}).get("tools") + self.assertIsInstance(tools, list) + self.assertTrue({tool.get("name") for tool in tools} >= set(REQUIRED_TOOLS)) initialize = self.stdio_rpc( process, @@ -1085,7 +1103,7 @@ def test_stdio_rejects_preinitialize_calls_and_accepts_cancel_notification(self) finally: self.stop_process(process) - def test_stdio_replays_duplicate_initialize_after_a_failed_probe(self) -> None: + def test_stdio_repeats_initialize_after_a_failed_probe(self) -> None: """Replay the sequence from issue #39: probe, initialize, initialize again.""" process = self.start_stdio_server() @@ -1111,11 +1129,24 @@ def test_stdio_replays_duplicate_initialize_after_a_failed_probe(self) -> None: ) self.assertEqual(first.get("result", {}).get("protocolVersion"), "2025-11-25") - replayed = self.stdio_rpc( + repeated = self.stdio_rpc( process, {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": params}, ) - self.assertEqual(replayed.get("result"), first.get("result")) + self.assertEqual(repeated.get("result"), first.get("result")) + + # Each handshake negotiates on its own, so a repeat that asks for + # another supported version gets that version rather than an error. + other_version = self.stdio_rpc( + process, + { + "jsonrpc": "2.0", + "id": "other-version", + "method": "initialize", + "params": {**params, "protocolVersion": "2025-06-18"}, + }, + ) + self.assertEqual(other_version.get("result", {}).get("protocolVersion"), "2025-06-18") self.stdio_send(process, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) self.assert_no_stdio_response(process) From 9abb9deb170e6bb239e443fe6ed4c25a0786afc9 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:19:17 +0000 Subject: [PATCH 13/25] Validate modern mirror headers and map protocol errors to HTTP statuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEP-2243 has a 2026-07-28 request repeat its version, method, and subject in headers so a gateway can route on them without reading the body. We check the mirror against the body instead, which gives up part of that intent: only the body can say which era a request belongs to, and a handshake-era client sends none of these headers. A contradiction between the two is one error, -32020, whichever header carries it. Modern errors also reach the client as HTTP statuses now — -32601 as 404, and the request-fault codes as 400 — while -32603 and every handshake-era error stay the 200 that clients of the older protocol read their JSON-RPC error out of. Co-authored-by: Cursor --- CHANGELOG.md | 25 +++- coding_tools_mcp/protocol.py | 121 ++++++++++++++- coding_tools_mcp/server.py | 85 +++++++++-- tests/compliance/test_mcp_contract.py | 206 +++++++++++++++++++++++++- 4 files changed, 413 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b979a86..561e41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,10 +103,27 @@ `tools/list`, and `tools/call` immediately. A `_meta` version this server does not speak is answered with `-32022` and the versions it does (`data.supported`); a missing or mistyped required `_meta` field is answered - with `-32602`. Available over STDIO in this change; HTTP support follows. - Requests without that `_meta` key — including legacy requests that carry - `_meta.progressToken`, and every `initialize` — keep the handshake behavior - they had. + with `-32602`. Requests without that `_meta` key — including legacy requests + that carry `_meta.progressToken`, and every `initialize` — keep the + handshake behavior they had. +- Streamable HTTP serves `2026-07-28` as well, with the mirror headers + SEP-2243 requires. Such a request must repeat its `_meta` protocol version + in `MCP-Protocol-Version` and its method in `Mcp-Method`; `tools/call`, + `resources/read`, and `prompts/get` must also repeat their subject + (`params.name`, or `params.uri` for `resources/read`) in `Mcp-Name`, either + literally or wrapped as `=?base64??=`. Any header that contradicts + the body — including a `2026-07-28` version header on a request whose body + is a handshake-era one — is answered with `400` and the new `-32020`. + Handshake-era requests are not asked for these headers and are unaffected. +- A `2026-07-28` request that fails now reports it in the HTTP status as well: + `-32601` is `404`, and `-32602`, `-32020`, and `-32022` are `400`. Any other + code, `-32603` included, stays `200` with the JSON-RPC error, which is also + what every handshake-era error keeps returning. +- `MCP-Protocol-Version` accepts any version this server speaks. A header + naming an unknown version is still refused with `400` and `-32600`, and + `data.supported` now lists both eras. +- CORS preflight allows `Mcp-Method` and `Mcp-Name`, and no longer allows + `Mcp-Session-Id`. - Results for `2026-07-28` requests carry `resultType: "complete"` and an `_meta.io.modelcontextprotocol/serverInfo`, and `tools/list` also carries the conservative cache hints `ttlMs: 0` and `cacheScope: "private"` on the result diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index e6baebb..f3d3bdd 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import copy from collections.abc import Mapping from dataclasses import dataclass @@ -14,6 +15,7 @@ LEGACY_PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18") LATEST_LEGACY_PROTOCOL_VERSION = LEGACY_PROTOCOL_VERSIONS[0] MODERN_PROTOCOL_VERSIONS = ("2026-07-28",) +KNOWN_PROTOCOL_VERSIONS = (*MODERN_PROTOCOL_VERSIONS, *LEGACY_PROTOCOL_VERSIONS) LEGACY_ERA = "legacy" MODERN_ERA = "modern" @@ -24,6 +26,20 @@ UNSUPPORTED_PROTOCOL_VERSION = -32022 MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 +HEADER_MISMATCH = -32020 + +# SEP-2243 lets a gateway route a modern request on its headers alone, so the +# headers must mirror the body they travel with. These methods name their +# subject in the body, and the name is mirrored in ``Mcp-Name``; the two this +# server does not implement are listed as well, because the mirror is a +# property of the request, not of what we can answer. +MIRRORED_NAME_METHODS = { + "tools/call": "name", + "resources/read": "uri", + "prompts/get": "name", +} +BASE64_SENTINEL_PREFIX = "=?base64?" +BASE64_SENTINEL_SUFFIX = "?=" MODERN_METHODS = frozenset( { @@ -124,6 +140,95 @@ def legacy_protocol_version_is_supported(version: Any) -> bool: return isinstance(version, str) and version in LEGACY_PROTOCOL_VERSIONS +def protocol_version_is_known(version: Any) -> bool: + return isinstance(version, str) and version in KNOWN_PROTOCOL_VERSIONS + + +def decode_mirror_header(value: str) -> str: + """Read one mirror header value, unwrapping a base64 sentinel if present. + + A value that cannot travel as an HTTP field is wrapped as + ``=?base64??=``. The affixes are matched exactly, so a value that + merely resembles one is compared as the literal it is. + """ + + if not (value.startswith(BASE64_SENTINEL_PREFIX) and value.endswith(BASE64_SENTINEL_SUFFIX)): + return value + payload = value[len(BASE64_SENTINEL_PREFIX) : -len(BASE64_SENTINEL_SUFFIX)] + try: + return base64.b64decode(payload, validate=True).decode("utf-8") + except ValueError as exc: # binascii.Error and UnicodeDecodeError both subclass it + raise JsonRpcError( + HEADER_MISMATCH, + "Mirror header carries a base64 sentinel that does not decode to UTF-8", + {"reason": "invalid_base64"}, + ) from exc + + +def validate_mirror_headers( + era: str, + method: str, + params: Mapping[str, Any], + *, + version_header: str | None, + method_header: str | None, + name_header: str | None, +) -> None: + """Check that a request's headers mirror the body they travel with. + + SEP-2243 asks a modern request to restate its version, method, and subject + in headers so a gateway can route on them alone. We read the body first + and enforce the mirror against it, which gives up part of that intent but + is the only way to tell the two eras apart: a legacy request carries no + such headers and is left alone, except that a modern version header over a + legacy body is a mismatch like any other. + """ + + if era != MODERN_ERA: + if version_header in MODERN_PROTOCOL_VERSIONS: + raise _mirror_error( + "MCP-Protocol-Version", + f"MCP-Protocol-Version {version_header} needs a request that states the same " + f"version in params._meta.{META_PROTOCOL_VERSION}", + "body_is_not_modern", + ) + return + + meta = params.get("_meta") + meta_version = meta.get(META_PROTOCOL_VERSION) if isinstance(meta, dict) else None + if version_header is None: + raise _mirror_error( + "MCP-Protocol-Version", + "MCP-Protocol-Version is required and must repeat the version in params._meta", + "missing", + ) + if version_header != meta_version: + raise _mirror_error( + "MCP-Protocol-Version", + "MCP-Protocol-Version does not match the version in params._meta", + "mismatch", + ) + if method_header is None: + raise _mirror_error("Mcp-Method", "Mcp-Method is required and must repeat the request method", "missing") + if method_header != method: + raise _mirror_error("Mcp-Method", "Mcp-Method does not match the request method", "mismatch") + subject = MIRRORED_NAME_METHODS.get(method) + if subject is None: + return + if name_header is None: + raise _mirror_error( + "Mcp-Name", + f"Mcp-Name is required for {method} and must repeat params.{subject}", + "missing", + ) + if decode_mirror_header(name_header) != params.get(subject): + raise _mirror_error("Mcp-Name", f"Mcp-Name does not match params.{subject}", "mismatch") + + +def _mirror_error(header: str, message: str, reason: str) -> JsonRpcError: + return JsonRpcError(HEADER_MISMATCH, message, {"header": header, "reason": reason}) + + def request_era(method: str, params: Mapping[str, Any]) -> str: """Decide which protocol era a request belongs to. @@ -220,7 +325,12 @@ def shape_result( return shaped -def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None: +def dispatch_rpc( + runtime: Any, + request: dict[str, Any], + *, + transport_protocol_version: str | None = None, +) -> dict[str, Any] | None: """Dispatch one MCP JSON-RPC request against a runtime, shared by all transports. The era is decided first, from the request itself: a modern request states @@ -228,7 +338,9 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None handshake this runtime keeps no record of. Neither era leaves state behind, so one runtime answers every client of the workspace. Transports add only their transport-specific framing (stream handling, status codes) around - this. Returns None for notifications and requests without an id. + this, and may report the legacy version their framing negotiated through + ``transport_protocol_version``; it is echoed and recorded, never acted on. + Returns None for notifications and requests without an id. """ request_id = request.get("id") @@ -240,7 +352,10 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None context = modern_request_context(params) result = _dispatch_modern(runtime, method, params, context) else: - context = RequestContext(era=LEGACY_ERA, protocol_version=LATEST_LEGACY_PROTOCOL_VERSION) + context = RequestContext( + era=LEGACY_ERA, + protocol_version=transport_protocol_version or LATEST_LEGACY_PROTOCOL_VERSION, + ) result = _dispatch_legacy(runtime, request, method, params, context) if result is None or request_id is None: return None diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 09c1d6f..8ec69e5 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -68,14 +68,19 @@ terminate_process_group, ) from .protocol import ( + HEADER_MISMATCH, + KNOWN_PROTOCOL_VERSIONS, LATEST_LEGACY_PROTOCOL_VERSION, - LEGACY_PROTOCOL_VERSIONS, - MODERN_PROTOCOL_VERSIONS, + MODERN_ERA, + UNSUPPORTED_PROTOCOL_VERSION, RequestContext, dispatch_rpc, jsonrpc_error, legacy_protocol_version_is_supported, + protocol_version_is_known, + request_era, response_id, + validate_mirror_headers, validate_rpc_envelope, ) from .project_context import ProjectContext, load_project_context @@ -1536,7 +1541,7 @@ def server_info_payload(self) -> dict[str, Any]: "server": SERVER_NAME, "title": SERVER_TITLE, "version": __version__, - "supported_protocol_versions": [*MODERN_PROTOCOL_VERSIONS, *LEGACY_PROTOCOL_VERSIONS], + "supported_protocol_versions": list(KNOWN_PROTOCOL_VERSIONS), **self._exec_environment_summary(), "auth_enabled": self.auth_enabled(), "dangerously_skip_all_permissions": self.dangerously_skip_all_permissions, @@ -4746,7 +4751,7 @@ def server_card_payload(runtime: Runtime, *, oauth_base_url: str | None = None) read_only = [name for name in names if annotations[name].get("readOnlyHint") is True] mutating = [name for name in names if annotations[name].get("readOnlyHint") is not True] payload = { - "supportedProtocolVersions": [*MODERN_PROTOCOL_VERSIONS, *LEGACY_PROTOCOL_VERSIONS], + "supportedProtocolVersions": list(KNOWN_PROTOCOL_VERSIONS), "server": { "name": SERVER_NAME, "title": SERVER_TITLE, @@ -4772,6 +4777,27 @@ def server_card_payload(runtime: Runtime, *, oauth_base_url: str | None = None) return payload +# A modern client reads the HTTP status as well as the JSON-RPC error, so the +# protocol errors that name a fault in the request are reported as such. Every +# other code — including -32603, which says the request was fine and we were +# not — stays a 200 carrying a JSON-RPC error, as the legacy era always does. +MODERN_ERROR_STATUSES = { + -32601: 404, + -32602: 400, + HEADER_MISMATCH: 400, + UNSUPPORTED_PROTOCOL_VERSION: 400, +} + + +def rpc_response_status(era: str, response: dict[str, Any]) -> int: + if era != MODERN_ERA: + return 200 + error = response.get("error") + if not isinstance(error, dict): + return 200 + return MODERN_ERROR_STATUSES.get(error.get("code"), 200) + + class MCPHandler(http.server.BaseHTTPRequestHandler): server_version = f"CodingToolsMCP/{__version__}" @@ -4904,12 +4930,15 @@ def do_POST(self) -> None: if self.headers.get_content_type().lower() != "application/json": self.send_rpc_error(-32600, "Content-Type must be application/json", status=415) return + # Which era a request belongs to is decided by its body, so a version + # header naming something from neither era is refused before the body + # is read: there is nothing to decide it against. protocol_version = self.headers.get("MCP-Protocol-Version") - if protocol_version and not legacy_protocol_version_is_supported(protocol_version): + if protocol_version and not protocol_version_is_known(protocol_version): self.send_rpc_error( -32600, "Unsupported MCP protocol version", - data={"supported": list(LEGACY_PROTOCOL_VERSIONS), "received": protocol_version}, + data={"supported": list(KNOWN_PROTOCOL_VERSIONS), "received": protocol_version}, ) return raw_length = self.headers.get("Content-Length") @@ -4952,21 +4981,47 @@ def do_POST(self) -> None: exc.code, exc.message, status=200, request_id=response_id(request), data=exc.data ) return - # Every request is served by the one workspace runtime. A client that - # still echoes an ``Mcp-Session-Id`` from an older server is served - # like any other rather than rejected, so an upgrade needs no client - # change. - response = self.handle_rpc(request) + # Every request is served by the one workspace runtime, and a client + # that still echoes an ``Mcp-Session-Id`` from an older server is + # served like any other rather than rejected. What the request must + # carry beyond that depends on its era, which only its body can decide. + method = str(request["method"]) + raw_params = request.get("params") + params = raw_params if isinstance(raw_params, dict) else {} + era = request_era(method, params) + try: + validate_mirror_headers( + era, + method, + params, + version_header=protocol_version, + method_header=self.headers.get("Mcp-Method"), + name_header=self.headers.get("Mcp-Name"), + ) + except JsonRpcError as exc: + self.send_rpc_error(exc.code, exc.message, request_id=response_id(request), data=exc.data) + return + response = self.handle_rpc(request, transport_protocol_version=protocol_version) if response is None: self.send_response(202) self.send_cors_headers() self.end_headers() return - self.send_json(response) + self.send_json(response, status=rpc_response_status(era, response)) - def handle_rpc(self, request: dict[str, Any]) -> dict[str, Any] | None: + def handle_rpc( + self, + request: dict[str, Any], + *, + transport_protocol_version: str | None = None, + ) -> dict[str, Any] | None: + legacy_version = ( + transport_protocol_version + if legacy_protocol_version_is_supported(transport_protocol_version) + else None + ) try: - return dispatch_rpc(self.runtime, request) + return dispatch_rpc(self.runtime, request, transport_protocol_version=legacy_version) except Exception as exc: # noqa: BLE001 - HTTP must always answer with JSON-RPC return jsonrpc_error(response_id(request), -32603, str(exc)) @@ -5333,7 +5388,7 @@ def send_cors_headers(self) -> None: self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS") self.send_header( "Access-Control-Allow-Headers", - "Accept, Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id", + "Accept, Authorization, Content-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name", ) def send_json( diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 9ac02ba..eaa6e7f 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -71,6 +71,18 @@ def modern_request( return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": body} +# The methods that name their subject in the body, and the params field a +# 2026-07-28 client repeats in Mcp-Name. +MIRRORED_NAME_METHODS = {"tools/call": "name", "resources/read": "uri", "prompts/get": "name"} + + +def base64_sentinel(value: str) -> str: + """Wrap a header value the way a client encodes one that is not ASCII.""" + + encoded = base64.b64encode(value.encode("utf-8")).decode("ascii") + return f"=?base64?{encoded}?=" + + class MCPContractTests(ComplianceTestCase): def test_initialize_succeeds_and_tools_list_is_available(self) -> None: tools = self.client.list_tools() @@ -344,6 +356,12 @@ def test_http_rejects_unsupported_protocol_version_header(self) -> None: self.assertIsNone(body.get("id")) self.assertEqual(body.get("error", {}).get("code"), -32600) self.assertIn("Unsupported MCP protocol version", body.get("error", {}).get("message", "")) + # Both eras are offered: the header alone cannot say which one the + # client meant to speak. + self.assertEqual( + body.get("error", {}).get("data", {}).get("supported"), + [MODERN_PROTOCOL_VERSION, "2025-11-25", "2025-06-18"], + ) def test_http_rejects_non_json_content_type(self) -> None: status, body = self.raw_http_post( @@ -456,6 +474,161 @@ def test_http_delete_is_rejected_without_disturbing_running_commands(self) -> No ) self.assertIn(killed.get("status"), {"killed", "exited"}) + def test_http_modern_request_succeeds_with_mirrored_headers(self) -> None: + status, response = self.modern_http_post( + modern_request(1, "tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}) + ) + self.assertEqual(status, 200, response) + result = response.get("result", {}) + self.assert_modern_result(result) + self.assertEqual(result.get("structuredContent", {}).get("path"), "src/math.js") + + ping_status, ping = self.modern_http_post(modern_request(2, "ping")) + self.assertEqual(ping_status, 200, ping) + self.assert_modern_result(ping.get("result", {})) + + listed_status, listed = self.modern_http_post(modern_request(3, "tools/list")) + self.assertEqual(listed_status, 200, listed) + self.assert_modern_result(listed.get("result", {})) + self.assertEqual(listed.get("result", {}).get("ttlMs"), 0) + + # A notification mirrors its method too, and is still answered with an + # empty 202 rather than a JSON-RPC response. + parsed = urllib.parse.urlparse(str(self.client.url)) + notified_status, _, notified_body = self.raw_base_http_request( + f"{parsed.scheme}://{parsed.netloc}", + "POST", + parsed.path or "/mcp", + body=json.dumps( + {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"_meta": modern_meta()}} + ).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, + "Mcp-Method": "notifications/cancelled", + }, + ) + self.assertEqual(notified_status, 202) + self.assertEqual(notified_body, "") + + def test_http_modern_name_header_accepts_a_base64_sentinel(self) -> None: + status, response = self.modern_http_post( + modern_request(1, "tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}), + headers={"Mcp-Name": base64_sentinel("read_file")}, + ) + self.assertEqual(status, 200, response) + self.assert_modern_result(response.get("result", {})) + + def test_http_modern_headers_must_mirror_the_request_body(self) -> None: + """Every mirror violation is one error: the headers contradict the body.""" + + call = modern_request(1, "tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}) + cases: list[tuple[str, dict[str, Any], dict[str, str], tuple[str, ...]]] = [ + ("missing version header", call, {}, ("MCP-Protocol-Version",)), + ("version header disagrees with _meta", call, {"MCP-Protocol-Version": "2025-11-25"}, ()), + ( + "modern version header over a legacy body", + {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + {"MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, "Mcp-Method": "tools/list"}, + (), + ), + ("missing method header", call, {}, ("Mcp-Method",)), + ("method header disagrees with body", call, {"Mcp-Method": "tools/list"}, ()), + ( + "missing method header on a notification", + {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"_meta": modern_meta()}}, + {}, + ("Mcp-Method",), + ), + ("missing name header", call, {}, ("Mcp-Name",)), + ("name header disagrees with params.name", call, {"Mcp-Name": "list_dir"}, ()), + ( + "name header sentinel is not base64", + call, + {"Mcp-Name": "=?base64?not valid base64!?="}, + (), + ), + ] + for name, request, headers, drop in cases: + with self.subTest(case=name): + status, response = self.modern_http_post(request, headers=headers, drop=drop) + self.assertEqual(status, 400, response) + self.assertEqual(response.get("error", {}).get("code"), -32020, response) + self.assertNotIn("result", response) + + def test_http_modern_name_header_is_required_by_method_name(self) -> None: + """The mirror is checked before the method is looked up. + + ``resources/read`` is one of the methods that names its subject, and + this server does not implement it. A request that mirrors correctly + gets that verdict; one that does not is refused before we ever find + out the method is unknown. + """ + + request = modern_request(1, "resources/read", {"uri": "file:///workspace/src/math.js"}) + status, response = self.modern_http_post( + request, + headers={"Mcp-Name": "file:///workspace/src/math.js"}, + ) + self.assertEqual(status, 404, response) + self.assertEqual(response.get("error", {}).get("code"), -32601) + + missing_status, missing = self.modern_http_post(request, drop=("Mcp-Name",)) + self.assertEqual(missing_status, 400, missing) + self.assertEqual(missing.get("error", {}).get("code"), -32020) + + def test_http_modern_protocol_errors_map_to_http_statuses(self) -> None: + unknown_status, unknown = self.modern_http_post(modern_request(1, "server/discover")) + self.assertEqual(unknown_status, 404, unknown) + self.assertEqual(unknown.get("error", {}).get("code"), -32601) + + invalid_status, invalid = self.modern_http_post( + modern_request(2, "tools/list", meta=modern_meta(drop=(META_CLIENT_CAPABILITIES,))) + ) + self.assertEqual(invalid_status, 400, invalid) + self.assertEqual(invalid.get("error", {}).get("code"), -32602) + + unsupported_status, unsupported = self.modern_http_post( + modern_request(3, "tools/list", meta=modern_meta({META_PROTOCOL_VERSION: "2025-11-25"})), + headers={"MCP-Protocol-Version": "2025-11-25"}, + ) + self.assertEqual(unsupported_status, 400, unsupported) + self.assertEqual(unsupported.get("error", {}).get("code"), -32022) + self.assertEqual( + unsupported.get("error", {}).get("data", {}).get("supported"), + [MODERN_PROTOCOL_VERSION], + ) + + # A handshake client reads only the JSON-RPC error, and mapping its + # errors onto statuses now would break it. + legacy_status, legacy = self.raw_http_post( + b'{"jsonrpc":"2.0","id":4,"method":"server/discover","params":{}}' + ) + self.assertEqual(legacy_status, 200, legacy) + self.assertEqual(legacy.get("error", {}).get("code"), -32601) + + def test_http_preflight_advertises_the_mirror_headers(self) -> None: + self.assertIsNotNone(self.client.url) + parsed = urllib.parse.urlparse(str(self.client.url)) + base = f"{parsed.scheme}://{parsed.netloc}" + status, headers, _ = self.raw_base_http_request( + base, + "OPTIONS", + parsed.path or "/mcp", + headers={ + "Origin": "http://localhost:3000", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Mcp-Method, Mcp-Name", + }, + ) + self.assertEqual(status, 204) + allowed = headers.get("access-control-allow-headers", "") + self.assertIn("Mcp-Method", allowed) + self.assertIn("Mcp-Name", allowed) + self.assertNotIn("Mcp-Session-Id", allowed) + self.assertNotIn("DELETE", headers.get("access-control-allow-methods", "")) + self.assertNotIn("DELETE", headers.get("allow", "")) + def test_http_discovery_endpoints_return_server_card_metadata(self) -> None: self.assertIsNotNone(self.client.url) parsed = urllib.parse.urlparse(str(self.client.url)) @@ -987,6 +1160,10 @@ def test_http_rejects_older_protocol_version_header(self) -> None: ) self.assertEqual(status, 400) self.assertEqual(response.get("error", {}).get("code"), -32600) + self.assertEqual( + response.get("error", {}).get("data", {}).get("supported"), + [MODERN_PROTOCOL_VERSION, "2025-11-25", "2025-06-18"], + ) def test_initialize_downgrades_unsupported_client_protocol(self) -> None: """A version the server cannot speak is answered with one it can. @@ -1510,6 +1687,7 @@ def raw_http_post( content_length: int | str | None = None, headers: dict[str, str] | None = None, path: str | None = None, + default_protocol_version: str | None = "2025-11-25", ) -> tuple[int, dict[str, Any]]: self.assertIsNotNone(self.client.url) parsed = urllib.parse.urlparse(str(self.client.url)) @@ -1520,8 +1698,8 @@ def raw_http_post( connection.putrequest("POST", path or parsed.path or "/mcp") connection.putheader("Accept", "application/json, text/event-stream") connection.putheader("Content-Type", content_type) - if not headers or "MCP-Protocol-Version" not in headers: - connection.putheader("MCP-Protocol-Version", "2025-11-25") + if default_protocol_version and (not headers or "MCP-Protocol-Version" not in headers): + connection.putheader("MCP-Protocol-Version", default_protocol_version) connection.putheader("Content-Length", str(len(body) if content_length is None else content_length)) for name, value in (headers or {}).items(): connection.putheader(name, value) @@ -1534,6 +1712,30 @@ def raw_http_post( finally: connection.close() + def modern_http_post( + self, + request: dict[str, Any], + *, + headers: dict[str, str] | None = None, + drop: tuple[str, ...] = (), + ) -> tuple[int, dict[str, Any]]: + """POST a 2026-07-28 request with the headers that mirror its body.""" + + method = str(request.get("method", "")) + params = request.get("params", {}) + mirrored = {"MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, "Mcp-Method": method} + subject = MIRRORED_NAME_METHODS.get(method) + if subject is not None and isinstance(params.get(subject), str): + mirrored["Mcp-Name"] = params[subject] + mirrored.update(headers or {}) + for name in drop: + mirrored.pop(name, None) + return self.raw_http_post( + json.dumps(request).encode("utf-8"), + headers=mirrored, + default_protocol_version=None, + ) + def raw_post_to_auth_server( self, url: str, From 190408b6fe3ad532b72203ea10111d1c00d594ec Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:36:57 +0000 Subject: [PATCH 14/25] Rework telemetry for the shared dual-era runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry still measured a client's handshake, which one shared runtime no longer has: a 2026-07-28 client that never sends initialize left the whole pipeline mute, and the client name recorded at the handshake only ever described whichever client connected first. A session is now activated by the first request or notification that passes envelope validation, in either era and before the method runs — otherwise a first call that fails would lose its tool_error — while ping never activates one, so an HTTP health probe against an idle server stays silent. Identity travels with the request that carried it. initialize emits its own handshake event with the negotiated version and the clientInfo it was given, once per handshake; a 2026-07-28 tool_error carries the clientInfo of that request, narrowed to a printable ASCII subset and truncated first, because the value is whatever the client says it is; a handshake-era tool_error carries none rather than borrowing a name from some other client. consecutive_failures and the 20-error budget are runtime-wide, and the docs say so. session_end gains the per-era request counts, the server/discover probe count, and the retained-output counters that server_info used to answer with — how often the budget was hit is a measurement of the process, not an answer to whichever client asked. Each protocol choice a process first serves is also logged as one line on stderr, telemetry on or off, so an operator can see which era their clients speak. Co-authored-by: Cursor --- CHANGELOG.md | 29 ++- coding_tools_mcp/protocol.py | 9 +- coding_tools_mcp/server.py | 11 +- coding_tools_mcp/telemetry.py | 209 +++++++++++++++---- coding_tools_mcp/transport_stdio.py | 3 + docs/telemetry.md | 58 +++++- tests/compliance/test_runtime_helpers.py | 46 ++++- tests/test_telemetry.py | 244 +++++++++++++++++++++-- 8 files changed, 524 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 561e41e..6511fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,10 +46,13 @@ head segment, one eighth of the per-stream budget) in addition to the rolling tail, so the command echo and first errors survive large outputs. `read_output` reports `head_retained_bytes` and `evicted_gap_bytes`. -- `server_info` exposes an `output_retention` block with eviction counters - (`evict_events`, `evicted_bytes_total`) and omitted-read counters - (`read_output_omitted_hits`, `poll_omitted_hits`) so operators can measure - how often clients hit evicted output. +- `server_info` exposes an `output_retention` block naming the per-stream + retention budget (`buffer_bytes_per_stream`, `head_bytes_per_stream`). How + often that budget was actually hit is a runtime-wide measurement rather than + an answer to one client, so the eviction counters (`evict_events`, + `evicted_bytes_total`) and omitted-read counters + (`read_output_omitted_hits`, `poll_omitted_hits`) are reported in the + telemetry `session_end` event instead. - `exec_command` and `read_output` tool descriptions now direct clients to redirect very large output to a file and page it with `read_file` / `search_text`. @@ -72,8 +75,7 @@ never returned. `initialize` is now idempotent: each one negotiates a version on its own and answers with it, so a repeat that names a different supported version is answered with that version instead of `-32600 Server is - already initialized with a different protocol version`. Only the first - handshake of a process records a telemetry session. + already initialized with a different protocol version`. - **Breaking:** `server_info` reports `supported_protocol_versions` (every version this server speaks, newest first) in place of `protocol_version` (the one version a session had negotiated), and the server card at @@ -85,6 +87,21 @@ failure reports `RUNTIME_DIR_UNWRITABLE` instead of moving a running command's directories, and the non-git diff fallback snapshots its patch baselines under the patch lock. +- Telemetry now measures the process rather than one client's handshake. A + session is activated by the first request or notification that passes + envelope validation — in either era, and before the method runs, so a first + call that fails still reports it — while `ping` never activates one, leaving + an HTTP health probe against an idle server silent. Client identity moves + with the request that carried it: `initialize` emits its own `handshake` + event with the negotiated version and the `clientInfo` it was given, a + `2026-07-28` `tool_error` carries the sanitized `clientInfo` of that + request, and no other event claims to know who is calling. + `consecutive_failures` and the 20-error budget are runtime-wide across every + client, `session_end` adds per-era request counts and a `server/discover` + probe count, and the retained-output counters that `server_info` used to + report travel with it. Each protocol choice a process first serves is also + logged as one line on stderr, telemetry on or off. See + [docs/telemetry.md](docs/telemetry.md). - **Behavior change:** `initialize` no longer fails with `-32602` when a client asks for a `protocolVersion` this server does not speak. As the handshake spec requires, the server now answers with an `InitializeResult` naming the diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index f3d3bdd..1e54c71 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -51,6 +51,9 @@ ) MODERN_CACHEABLE_METHODS = frozenset({"tools/list"}) MODERN_RESULT_TYPE = "complete" +# The method a dual-era client probes with before it decides to handshake. +# Not implemented yet, but named here because the probe is worth counting. +DISCOVER_METHOD = "server/discover" @dataclass(frozen=True) @@ -348,7 +351,11 @@ def dispatch_rpc( validate_rpc_envelope(request) method = request["method"] params = rpc_params(request) - if request_era(method, params) == MODERN_ERA: + era = request_era(method, params) + # Before the method runs: a first request that fails must still be + # able to report the failure it caused. + runtime.telemetry.record_request(era, method) + if era == MODERN_ERA: context = modern_request_context(params) result = _dispatch_modern(runtime, method, params, context) else: diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 8ec69e5..f3459c5 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -1348,7 +1348,7 @@ def close(self) -> None: self._closed = True if self._owns_command_manager: self.command_manager.close() - self.telemetry.finish() + self.telemetry.finish(output_retention=self.command_manager.retention_stats_snapshot()) @property def commands(self) -> dict[str, CommandRun]: @@ -1556,10 +1556,12 @@ def server_info_payload(self) -> dict[str, Any]: "shell_env_inherit": self.shell_env_policy.inherit, "shell_env_include_only": list(self.shell_env_policy.include_only), "shell_env_exclude": list(self.shell_env_policy.exclude), + # The static budget only: how often it was actually hit is a + # runtime-wide counter and is reported in telemetry, not to + # whichever client happened to ask. "output_retention": { "buffer_bytes_per_stream": COMMAND_BUFFER_BYTES, "head_bytes_per_stream": COMMAND_BUFFER_BYTES // COMMAND_HEAD_BUFFER_DIVISOR, - **self.command_manager.retention_stats_snapshot(), }, "endpoint_path": MCP_ENDPOINT_PATH, "project_context": { @@ -1667,17 +1669,18 @@ def emit_tool_trace( *, context: RequestContext | None = None, ) -> None: - # `context` carries the per-request facts telemetry will label traces - # with once observability is wired to it; nothing reads it yet. raw_error = payload.get("error") error = raw_error if isinstance(raw_error, dict) else {} duration_ms = int((time.time() - started_at) * 1000) + # `context` is passed on as the opaque per-request fact it is: the + # runtime neither reads the client identity in it nor branches on it. self.telemetry.record_tool_call( name, ok=bool(payload.get("ok")), error_code=error.get("code"), duration_ms=duration_ms, truncated=bool(payload.get("truncated")), + context=context, ) if os.environ.get(f"{ENV_PREFIX}_TRACE") != "1": return diff --git a/coding_tools_mcp/telemetry.py b/coding_tools_mcp/telemetry.py index 3f32d25..5efb525 100644 --- a/coding_tools_mcp/telemetry.py +++ b/coding_tools_mcp/telemetry.py @@ -19,9 +19,11 @@ ``~/.coding-tools-mcp/id``, used only to de-duplicate active-user counts; deleting that file resets the identity. -Events are emitted only for sessions that completed a real MCP ``initialize`` -handshake, so importing this module or exercising a Runtime directly (as unit -tests do) produces no traffic. +A session is activated by the first request or notification that passes +envelope validation, whichever era it belongs to, and ``ping`` never activates +one: one runtime serves every client of a workspace, and a client that never +handshakes still uses the server. Importing this module, or exercising a +Runtime without dispatching anything through it, produces no traffic. """ from __future__ import annotations @@ -30,16 +32,19 @@ import json import os import platform +import string import sys import threading import time import uuid +from collections.abc import Mapping from pathlib import Path from typing import Any from urllib.request import Request, urlopen from . import __version__ from .envutils import ENV_PREFIX, truthy_env, utc_now +from .protocol import DISCOVER_METHOD, MODERN_ERA POSTHOG_ENDPOINT = "https://us.i.posthog.com/batch/" # Public write-only ingest key: it can create events but never read them back. @@ -52,9 +57,21 @@ SEND_TIMEOUT_SECONDS = 3.0 _LABEL_LIMIT = 64 +_CLIENT_LABEL_LIMIT = 40 +# clientInfo is whatever the client says it is, so it is narrowed to a +# printable ASCII subset before it can become an event property: anything else +# is either an injection into the log line or unbounded cardinality. +_CLIENT_LABEL_CHARS = frozenset(string.ascii_letters + string.digits + " .,_-+/@:()") _OFF_VALUES = {"0", "off", "false", "no", "disable", "disabled"} _DURATION_BUCKETS = ((100, "dur_lt_100ms"), (1_000, "dur_lt_1s"), (10_000, "dur_lt_10s")) _DURATION_OVERFLOW = "dur_gte_10s" +_RETENTION_COUNTERS = ( + "evict_events", + "evicted_bytes_total", + "read_output_omitted_hits", + "poll_omitted_hits", +) +_LOG_PREFIX = "coding-tools-mcp" def telemetry_mode() -> str: @@ -77,6 +94,67 @@ def _label(value: Any) -> str | None: return text[:_LABEL_LIMIT] if text else None +def _client_label(value: Any) -> str | None: + """Normalize one self-reported ``clientInfo`` field into an event property. + + Unlike :func:`_label`, which shortens values this server produced itself, + this drops every character outside a printable ASCII subset — control + characters, newlines, and anything that would turn a name into free-form + text — before truncating. + """ + + if value is None: + return None + text = "".join(character for character in str(value) if character in _CLIENT_LABEL_CHARS).strip() + return text[:_CLIENT_LABEL_LIMIT] if text else None + + +def _client_identity(client_info: Any) -> tuple[str | None, str | None]: + """Read the sanitized ``name`` and ``version`` a client reported, if any. + + Only those two keys are read; a client may put anything else in the object + and none of it reaches an event. + """ + + if not isinstance(client_info, Mapping): + return None, None + return _client_label(client_info.get("name")), _client_label(client_info.get("version")) + + +def _request_identity(context: Any) -> tuple[str | None, str | None]: + """Name the client of one request, when the request itself named it. + + Only a modern request carries an identity, in the ``_meta`` the runtime + handed on as an opaque context. A legacy request is left anonymous: it + named itself in a handshake this runtime keeps no record of, and borrowing + a name from some other client's handshake would attribute a failure to a + client that never made the call. + """ + + if getattr(context, "era", None) != MODERN_ERA: + return None, None + return _client_identity(getattr(context, "client_info", None)) + + +_first_seen_lock = threading.Lock() +_first_seen: set[str] = set() + + +def note_first_appearance(key: str, message: str) -> None: + """Log one protocol choice the first time this process serves it. + + Written to stderr unconditionally — over stdio, stdout is the MCP wire — + and never repeated, so an operator can tell from the log which era their + clients actually speak without turning any tracing on. + """ + + with _first_seen_lock: + if key in _first_seen: + return + _first_seen.add(key) + print(f"{_LOG_PREFIX}: {message}", file=sys.stderr, flush=True) + + def _looks_like_install_id(value: str) -> bool: return len(value) == 32 and all(character in "0123456789abcdef" for character in value) @@ -189,12 +267,14 @@ def _get_sender() -> _Sender: class SessionTelemetry: - """Per-session in-memory counters emitted as closed-schema events. + """Per-runtime in-memory counters emitted as closed-schema events. ``record_tool_call`` only increments dictionary counters under its lock; - event dictionaries are built after the lock is released. Events exist only - for sessions activated by a real MCP ``initialize`` - (``record_session_start``). + event dictionaries are built after the lock is released. One runtime is + shared by every client of its workspace, so a "session" is the runtime's + lifetime rather than one client's: it is activated by the first request + that reaches :meth:`record_request` (or by a handshake) and closed once, + when the runtime is closed. """ def __init__(self, *, permission_mode: str, transport: str = "stdio") -> None: @@ -210,10 +290,10 @@ def __init__(self, *, permission_mode: str, transport: str = "stdio") -> None: "session_id": self._session_id, "$process_person_profile": False, } - self._client_name: str | None = None - self._client_version: str | None = None - self._protocol_version: str | None = None self._tools: dict[str, dict[str, Any]] = {} + self._legacy_requests = 0 + self._modern_requests = 0 + self._discover_probes = 0 self._error_events_sent = 0 self._errors_dropped = 0 self._failure_streak: tuple[str, int] | None = None @@ -221,20 +301,73 @@ def __init__(self, *, permission_mode: str, transport: str = "stdio") -> None: self._finished = False self._lock = threading.Lock() - def record_session_start(self, client_info: dict[str, Any] | None, protocol_version: str) -> None: + def record_request(self, era: str, method: str) -> None: + """Count one envelope-valid request and activate on the first of them. + + Called before the method runs, so a first request that fails still + reports its ``tool_error``. ``ping`` is counted but never activates: an + HTTP health probe must not conjure a session out of an idle server. + """ + with self._lock: - if self._active or self._finished: - return - self._active = True - if client_info: - self._client_name = _label(client_info.get("name")) - self._client_version = _label(client_info.get("version")) - self._protocol_version = _label(protocol_version) - if telemetry_mode() != "off": + if era == MODERN_ERA: + self._modern_requests += 1 + else: + self._legacy_requests += 1 + if method == DISCOVER_METHOD: + self._discover_probes += 1 + activated = self._activate_locked() if method != "ping" else False + if era == MODERN_ERA: + note_first_appearance("modern-request", f"modern client request ({method})") + if method == DISCOVER_METHOD: + note_first_appearance("discover-probe", f"{DISCOVER_METHOD} probe") + if activated: self._emit([self._event("session_start", {})], wake=True) + def record_session_start(self, client_info: dict[str, Any] | None, protocol_version: str) -> None: + """Record one legacy handshake, activating the session if it is the first. + + Every ``initialize`` emits its own ``handshake`` event — a connector + that probes, falls back, and handshakes again produces several — while + ``session_start`` is emitted at most once. The client identity belongs + to the handshake rather than to the session: the next request may come + from an entirely different client. + """ + + with self._lock: + activated = self._activate_locked() + note_first_appearance("legacy-handshake", f"legacy client handshake ({protocol_version})") + if telemetry_mode() == "off": + return + events = [self._event("session_start", {})] if activated else [] + client_name, client_version = _client_identity(client_info) + events.append( + self._event( + "handshake", + { + "protocol_version": _label(protocol_version), + "client_name": client_name, + "client_version": client_version, + }, + ) + ) + self._emit(events, wake=True) + + def _activate_locked(self) -> bool: + if self._active or self._finished: + return False + self._active = True + return True + def record_tool_call( - self, tool: str, *, ok: bool, error_code: str | None, duration_ms: int, truncated: bool + self, + tool: str, + *, + ok: bool, + error_code: str | None, + duration_ms: int, + truncated: bool, + context: Any = None, ) -> None: emit_error: tuple[str, int] | None = None with self._lock: @@ -266,6 +399,7 @@ def record_tool_call( else: self._errors_dropped += 1 if emit_error is not None and telemetry_mode() != "off": + client_name, client_version = _request_identity(context) self._emit( [ self._event( @@ -275,12 +409,14 @@ def record_tool_call( "error_code": emit_error[0], "duration_ms": duration_ms, "consecutive_failures": emit_error[1], + "client_name": client_name, + "client_version": client_version, }, ) ] ) - def finish(self) -> None: + def finish(self, *, output_retention: Mapping[str, int] | None = None) -> None: with self._lock: if self._finished: return @@ -302,26 +438,23 @@ def finish(self) -> None: properties[f"err_{code}"] = count properties.update(stats["buckets"]) events.append(self._event("tool_summary", properties)) - events.append( - self._event( - "session_end", - { - "duration_ms": duration_ms, - "tool_calls": sum(stats["calls"] for stats in self._tools.values()), - "distinct_tools": len(self._tools), - "errors_dropped": self._errors_dropped, - }, - ) - ) + end_properties: dict[str, Any] = { + "duration_ms": duration_ms, + "tool_calls": sum(stats["calls"] for stats in self._tools.values()), + "distinct_tools": len(self._tools), + "errors_dropped": self._errors_dropped, + "legacy_requests": self._legacy_requests, + "modern_requests": self._modern_requests, + "discover_probes": self._discover_probes, + } + for counter in _RETENTION_COUNTERS: + value = output_retention.get(counter, 0) if output_retention else 0 + end_properties[counter] = int(value) + events.append(self._event("session_end", end_properties)) self._emit(events, wake=True) def _event(self, name: str, properties: dict[str, Any]) -> dict[str, Any]: - merged: dict[str, Any] = { - **self._base_properties, - "client_name": self._client_name, - "client_version": self._client_version, - "protocol_version": self._protocol_version, - } + merged: dict[str, Any] = dict(self._base_properties) merged.update(properties) return { "event": name, diff --git a/coding_tools_mcp/transport_stdio.py b/coding_tools_mcp/transport_stdio.py index b35d74c..82f5d0d 100644 --- a/coding_tools_mcp/transport_stdio.py +++ b/coding_tools_mcp/transport_stdio.py @@ -5,9 +5,12 @@ from typing import Any, Protocol, TextIO from .protocol import RequestContext, dispatch_rpc, invalid_request_response, jsonrpc_error +from .telemetry import SessionTelemetry class StdioRuntime(Protocol): + telemetry: SessionTelemetry + def initialize( self, client_info: dict[str, Any] | None = None, diff --git a/docs/telemetry.md b/docs/telemetry.md index ccc9fab..fa8ae39 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -33,19 +33,65 @@ version strings assembled by one function (`coding_tools_mcp/telemetry.py`). It is structurally incapable of carrying paths, arguments, or file contents. Every event carries: package version, OS platform and architecture, Python -`major.minor`, transport (`stdio`/`http`), permission mode, MCP protocol -version, the connecting client's `clientInfo` name and version (truncated to -64 characters), a random per-session id, and the anonymous install id. +`major.minor`, transport (`stdio`/`http`), permission mode, a random +per-session id, and the anonymous install id. No client identity and no +protocol version is carried by every event: one server process answers every +client of its workspace, so a value recorded once would only ever describe +whichever client connected first. | Event | When | Additional properties | | --- | --- | --- | -| `session_start` | MCP `initialize` completes | — | -| `tool_error` | a tool call fails (max 20 per session) | tool name, error code, duration ms, consecutive-failure count | +| `session_start` | the first request or notification of the session, `ping` excepted | — | +| `handshake` | every MCP `initialize` | negotiated protocol version, the client's `clientInfo` name and version | +| `tool_error` | a tool call fails (max 20 per session) | tool name, error code, duration ms, consecutive-failure count, and for a 2026-07-28 request the `clientInfo` name and version it carried | | `tool_summary` | session ends, one per tool used | calls, ok, errors, per-error-code counts, duration buckets, truncation count | -| `session_end` | session ends | session duration, total calls, distinct tools, dropped error-event count | +| `session_end` | session ends | session duration, total calls, distinct tools, dropped error-event count, handshake-era and 2026-07-28 request counts, `server/discover` probe count, retained-output eviction and omitted-read counters | A typical session produces 5–15 events totalling a few kilobytes. +## What a session is + +A session is one server process, not one client: every client of a workspace +is served by the same runtime, and neither protocol era leaves a session +behind on the server. + +- The session is activated by the first request or notification that passes + envelope validation, whichever era it belongs to, and before the method + runs — so a first call that fails still reports its `tool_error`. A client + that never sends `initialize` is measured like any other. +- `ping` never activates a session. An HTTP health probe against an idle + server produces no events at all. +- `consecutive_failures` on `tool_error` counts consecutive failures of one + tool runtime-wide, across every client of the process. It is not a + single client's failure streak, and must not be read as one. +- The 20-error budget per session is likewise a whole-process budget, shared + by every client; `session_end` reports how many error events were dropped + once it ran out. +- A long-running HTTP server emits `session_start` once when it first serves + a client and `tool_summary`/`session_end` once when it shuts down, however + many clients it served in between. + +`clientInfo` is whatever a client says it is, so both fields are narrowed to a +printable ASCII subset and truncated to 40 characters before they can become +event properties. Only `name` and `version` are read; a handshake-era +`tool_error` carries no identity at all, because the request that failed did +not name one. + +## First-appearance server log + +Independently of telemetry — including when telemetry is off — the server +writes one line to stderr the first time a process sees each protocol choice, +so an operator can tell from the log which era their clients actually speak: + +```text +coding-tools-mcp: legacy client handshake (2025-11-25) +coding-tools-mcp: modern client request (tools/list) +coding-tools-mcp: server/discover probe +``` + +Each line appears at most once per process and only ever on stderr; over +stdio, stdout is the MCP wire. + ## What is never collected File paths, file contents, tool arguments, command lines, environment diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index 1040c91..e879511 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -13,10 +13,12 @@ from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory +from typing import Any from unittest.mock import patch from coding_tools_mcp import server as server_module from coding_tools_mcp import processes as processes_module +from coding_tools_mcp import telemetry as telemetry_module from coding_tools_mcp.patching import AtomicPatchCommitter, FileBaseline, StagedFile from coding_tools_mcp.server import ( LANDLOCK_ACCESS_FS_IOCTL_DEV, @@ -40,6 +42,19 @@ from tests.compliance.fixtures import git_fixture_preflight_error, init_git +class _RecordingSender: + """Stands in for the telemetry sender and keeps the events in memory.""" + + def __init__(self, events: list[dict[str, Any]]) -> None: + self.events = events + + def enqueue(self, events: list[dict[str, Any]], *, wake: bool = False) -> None: + self.events.extend(events) + + def flush(self) -> None: + pass + + @contextmanager def fake_landlock_exec() -> Iterator[dict[str, object]]: """Patch landlock + Popen so exec_command runs without spawning a process. @@ -1417,8 +1432,9 @@ def test_read_output_serves_retained_head_before_evicted_gap(self) -> None: self.assertEqual(third.get("content"), data[60:].decode()) self.assertIsNone(third.get("next_offset")) - def test_output_retention_counters_and_server_info_track_evicted_output(self) -> None: + def test_output_retention_counters_track_evicted_output_and_reach_telemetry(self) -> None: data = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!?" + events: list[dict[str, Any]] = [] with TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="trusted") with subprocess.Popen([sys.executable, "-c", ""], stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process: @@ -1440,19 +1456,29 @@ def test_output_retention_counters_and_server_info_track_evicted_output(self) -> stats = runtime.command_manager.retention_stats_snapshot() self.assertEqual(stats["read_output_omitted_hits"], 1) + # server_info reports the static budget; how often it was hit + # is a runtime-wide counter that belongs to telemetry. retention = runtime.server_info_payload()["output_retention"] - self.assertEqual(retention["evict_events"], 1) - self.assertEqual(retention["evicted_bytes_total"], 32) - self.assertEqual(retention["read_output_omitted_hits"], 1) self.assertEqual( - retention["buffer_bytes_per_stream"], - server_module.COMMAND_BUFFER_BYTES, - ) - self.assertEqual( - retention["head_bytes_per_stream"], - server_module.COMMAND_BUFFER_BYTES // 8, + retention, + { + "buffer_bytes_per_stream": server_module.COMMAND_BUFFER_BYTES, + "head_bytes_per_stream": server_module.COMMAND_BUFFER_BYTES // 8, + }, ) + with patch.object(telemetry_module, "telemetry_mode", lambda: "on"): + with patch.object(telemetry_module, "_get_sender", lambda: _RecordingSender(events)): + runtime.telemetry.record_request("legacy", "tools/call") + runtime.close() + + session_end = next(event for event in events if event["event"] == "session_end") + properties = session_end["properties"] + self.assertEqual(properties["evict_events"], 1) + self.assertEqual(properties["evicted_bytes_total"], 32) + self.assertEqual(properties["read_output_omitted_hits"], 1) + self.assertEqual(properties["poll_omitted_hits"], 0) + def test_git_convenience_tools(self) -> None: if server_module.shutil.which("git") is None: self.skipTest("git is not available") diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 10d6fea..8d0a714 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -44,6 +44,14 @@ def flush(self) -> None: pass +LEGACY_PROTOCOL_VERSION = "2025-11-25" +MODERN_PROTOCOL_VERSION = "2026-07-28" +MODERN_META = { + "io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION, + "io.modelcontextprotocol/clientCapabilities": {}, +} + + def _initialize(runtime: Runtime, client_name: str = "test-client") -> None: response = dispatch_rpc( runtime, @@ -57,6 +65,36 @@ def _initialize(runtime: Runtime, client_name: str = "test-client") -> None: assert response is not None and "error" not in response +def _modern_request( + runtime: Runtime, + method: str, + params: dict[str, object] | None = None, + *, + client_info: dict[str, object] | None = None, +) -> dict[str, object] | None: + """Dispatch one 2026-07-28 request, which states its version per request.""" + + meta = dict(MODERN_META) + if client_info is not None: + meta["io.modelcontextprotocol/clientInfo"] = client_info + body = dict(params or {}) + body["_meta"] = meta + return dispatch_rpc(runtime, {"jsonrpc": "2.0", "id": 7, "method": method, "params": body}) + + +def _events_by_name(sender: _CapturingSender) -> dict[str, list[dict[str, object]]]: + grouped: dict[str, list[dict[str, object]]] = {} + for event in sender.events: + grouped.setdefault(str(event["event"]), []).append(event) + return grouped + + +def _properties(event: dict[str, object]) -> dict[str, object]: + properties = event["properties"] + assert isinstance(properties, dict) + return properties + + class TelemetryModeTests(unittest.TestCase): def test_default_is_on(self) -> None: with scrubbed_env(): @@ -142,47 +180,62 @@ def test_payload_never_contains_paths_arguments_or_content(self) -> None: def test_session_events_carry_the_closed_schema(self) -> None: sender = _run_probe_session() - by_name: dict[str, list[dict[str, object]]] = {} - for event in sender.events: - by_name.setdefault(str(event["event"]), []).append(event) + by_name = _events_by_name(sender) self.assertEqual(len(by_name["session_start"]), 1) + self.assertEqual(len(by_name["handshake"]), 1) self.assertEqual(len(by_name["session_end"]), 1) self.assertEqual(len(by_name["tool_error"]), 2) - properties = by_name["session_start"][0]["properties"] - assert isinstance(properties, dict) + properties = _properties(by_name["session_start"][0]) self.assertEqual(properties["$process_person_profile"], False) self.assertEqual(properties["transport"], "stdio") self.assertEqual(properties["permission_mode"], "safe") - # clientInfo values are enum-like labels, truncated, and expected here. - self.assertEqual(properties["client_name"], "clientinfo-probe") + # One runtime serves every client of the workspace, so only the + # handshake and the request that failed name a client at all. + for event in sender.events: + if event["event"] in {"handshake", "tool_error"}: + continue + with self.subTest(event=event["event"]): + aggregate = _properties(event) + for field in ("client_name", "client_version", "protocol_version"): + self.assertNotIn(field, aggregate) + + handshake = _properties(by_name["handshake"][0]) + self.assertEqual(handshake["client_name"], "clientinfo-probe") + self.assertEqual(handshake["client_version"], "9.9.9") + self.assertEqual(handshake["protocol_version"], LEGACY_PROTOCOL_VERSION) errors = by_name["tool_error"] - first = errors[0]["properties"] - second = errors[1]["properties"] - assert isinstance(first, dict) and isinstance(second, dict) + first = _properties(errors[0]) + second = _properties(errors[1]) self.assertEqual(first["tool"], "read_file") self.assertEqual(first["error_code"], "NOT_FOUND") self.assertEqual(first["consecutive_failures"], 1) self.assertEqual(second["consecutive_failures"], 2) + # The failing calls were made straight against the runtime, so they + # carry no request context and therefore no client identity. + self.assertIsNone(first["client_name"]) + self.assertIsNone(first["client_version"]) - summaries = { - str(event["properties"]["tool"]): event["properties"] # type: ignore[index] - for event in by_name["tool_summary"] - } + summaries = {str(_properties(event)["tool"]): _properties(event) for event in by_name["tool_summary"]} self.assertEqual(summaries["read_file"]["calls"], 2) self.assertEqual(summaries["read_file"]["ok"], 0) self.assertEqual(summaries["read_file"]["err_NOT_FOUND"], 2) self.assertEqual(summaries["check_exec_environment"]["calls"], 1) self.assertEqual(summaries["check_exec_environment"]["ok"], 1) + self.assertNotIn("client_name", summaries["read_file"]) - end = by_name["session_end"][0]["properties"] - assert isinstance(end, dict) + end = _properties(by_name["session_end"][0]) self.assertEqual(end["tool_calls"], 3) self.assertEqual(end["distinct_tools"], 2) self.assertEqual(end["errors_dropped"], 0) + self.assertEqual(end["legacy_requests"], 1) + self.assertEqual(end["modern_requests"], 0) + self.assertEqual(end["discover_probes"], 0) + for counter in ("evict_events", "evicted_bytes_total", "read_output_omitted_hits", "poll_omitted_hits"): + self.assertEqual(end[counter], 0) - def test_sessions_without_initialize_emit_nothing(self) -> None: + def test_a_runtime_that_serves_no_request_emits_nothing(self) -> None: sender = _CapturingSender() with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): with tempfile.TemporaryDirectory() as tmp: @@ -192,11 +245,123 @@ def test_sessions_without_initialize_emit_nothing(self) -> None: runtime.close() self.assertEqual(sender.events, []) + def test_a_ping_only_runtime_emits_nothing(self) -> None: + sender = _CapturingSender() + with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + for request_id in (1, 2): + dispatch_rpc(runtime, {"jsonrpc": "2.0", "id": request_id, "method": "ping", "params": {}}) + _modern_request(runtime, "ping") + runtime.close() + self.assertEqual(sender.events, [], "an HTTP health probe must not create a session") + + def test_a_modern_client_that_never_handshakes_produces_a_session(self) -> None: + sender = _CapturingSender() + with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + # The very first request fails: activation happens before the + # method runs, so its tool_error must not be lost. + _modern_request( + runtime, + "tools/call", + {"name": "read_file", "arguments": {"path": "missing.txt"}}, + client_info={"name": "modern-probe", "version": "2.0"}, + ) + _modern_request(runtime, "tools/list") + runtime.close() + + by_name = _events_by_name(sender) + self.assertEqual(len(by_name["session_start"]), 1) + self.assertNotIn("handshake", by_name) + self.assertEqual(len(by_name["tool_error"]), 1) + error = _properties(by_name["tool_error"][0]) + self.assertEqual(error["tool"], "read_file") + self.assertEqual(error["client_name"], "modern-probe") + self.assertEqual(error["client_version"], "2.0") + end = _properties(by_name["session_end"][0]) + self.assertEqual(end["modern_requests"], 2) + self.assertEqual(end["legacy_requests"], 0) + + def test_discover_probes_are_counted_and_do_not_need_a_handshake(self) -> None: + sender = _CapturingSender() + with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + probe = dispatch_rpc( + runtime, {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {}} + ) + assert probe is not None + self.assertEqual(probe["error"]["code"], -32601) + _initialize(runtime) + runtime.close() + + by_name = _events_by_name(sender) + self.assertEqual(len(by_name["session_start"]), 1) + end = _properties(by_name["session_end"][0]) + self.assertEqual(end["discover_probes"], 1) + self.assertEqual(end["legacy_requests"], 2) + + def test_self_reported_client_identity_is_sanitized(self) -> None: + sender = _CapturingSender() + with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + _modern_request( + runtime, + "tools/call", + {"name": "read_file", "arguments": {"path": "missing.txt"}}, + client_info={ + "name": "evil\r\nclient\u4e2d\x07" + "x" * 200, + "version": "1.0\n", + "secret": "must-not-travel", + }, + ) + runtime.close() + + error = _properties(_events_by_name(sender)["tool_error"][0]) + self.assertEqual(error["client_name"], "evilclient" + "x" * 30) + self.assertEqual(error["client_version"], "1.0") + self.assertNotIn("must-not-travel", json.dumps(sender.events)) + + def test_every_handshake_is_recorded_but_the_session_starts_once(self) -> None: + sender = _CapturingSender() + with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + _initialize(runtime, client_name="first-connector") + _initialize(runtime, client_name="second-connector") + runtime.close() + + by_name = _events_by_name(sender) + self.assertEqual(len(by_name["session_start"]), 1) + self.assertEqual( + [_properties(event)["client_name"] for event in by_name["handshake"]], + ["first-connector", "second-connector"], + ) + + def test_output_retention_counters_travel_with_session_end(self) -> None: + sender = _CapturingSender() + with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + _initialize(runtime) + runtime.command_manager.record_output_eviction("stdout", 512) + runtime.command_manager.record_omitted_read("read_output") + runtime.close() + + end = _properties(_events_by_name(sender)["session_end"][0]) + self.assertEqual(end["evict_events"], 1) + self.assertEqual(end["evicted_bytes_total"], 512) + self.assertEqual(end["read_output_omitted_hits"], 1) + self.assertEqual(end["poll_omitted_hits"], 0) + def test_error_events_are_capped_and_drops_are_counted(self) -> None: sender = _CapturingSender() with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): session = SessionTelemetry(permission_mode="safe") - session.record_session_start({"name": "cap"}, "2025-11-25") + session.record_request("legacy", "tools/call") for _ in range(ERROR_EVENTS_PER_SESSION + 5): session.record_tool_call( "apply_patch", ok=False, error_code="PATCH_CONTEXT_MISMATCH", duration_ms=5, truncated=False @@ -213,7 +378,7 @@ def test_duration_buckets_and_finish_is_idempotent(self) -> None: sender = _CapturingSender() with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): session = SessionTelemetry(permission_mode="safe") - session.record_session_start(None, "2025-11-25") + session.record_session_start(None, LEGACY_PROTOCOL_VERSION) for duration in (50, 500, 5_000, 50_000): session.record_tool_call("exec_command", ok=True, error_code=None, duration_ms=duration, truncated=True) session.finish() @@ -228,11 +393,50 @@ def test_duration_buckets_and_finish_is_idempotent(self) -> None: self.assertEqual(len([event for event in sender.events if event["event"] == "session_end"]), 1) +class FirstAppearanceLogTests(unittest.TestCase): + """The one-line stderr notes an operator reads to see which era clients speak.""" + + def setUp(self) -> None: + self._saved = set(telemetry._first_seen) + telemetry._first_seen.clear() + + def tearDown(self) -> None: + telemetry._first_seen.clear() + telemetry._first_seen.update(self._saved) + + def test_each_protocol_choice_is_logged_once_to_stderr(self) -> None: + stderr = io.StringIO() + # Logged for the operator, not for us: telemetry being off changes nothing. + with scrubbed_env(CODING_TOOLS_MCP_TELEMETRY="off"), contextlib.redirect_stderr(stderr): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + _initialize(runtime) + _initialize(runtime) + _modern_request(runtime, "tools/list") + _modern_request(runtime, "ping") + for request_id in (1, 2): + dispatch_rpc( + runtime, + {"jsonrpc": "2.0", "id": request_id, "method": "server/discover", "params": {}}, + ) + runtime.close() + + lines = [line for line in stderr.getvalue().splitlines() if line.startswith("coding-tools-mcp:")] + self.assertEqual( + lines, + [ + f"coding-tools-mcp: legacy client handshake ({LEGACY_PROTOCOL_VERSION})", + "coding-tools-mcp: modern client request (tools/list)", + "coding-tools-mcp: server/discover probe", + ], + ) + + class DocumentationDriftTests(unittest.TestCase): def test_documented_schema_matches_emitted_events(self) -> None: doc = (Path(__file__).resolve().parents[1] / "docs" / "telemetry.md").read_text(encoding="utf-8") emitted = {str(event["event"]) for event in _run_probe_session().events} - self.assertEqual(emitted, {"session_start", "tool_error", "tool_summary", "session_end"}) + self.assertEqual(emitted, {"session_start", "handshake", "tool_error", "tool_summary", "session_end"}) for name in emitted: self.assertIn(f"`{name}`", doc) self.assertIn(f"max {ERROR_EVENTS_PER_SESSION} per session", doc) From 43e3b0f7a7239ea7575b570ac17ae77e4d06080c Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:45:58 +0000 Subject: [PATCH 15/25] Add the dual-era compliance suite and official SDK smoke The per-error paths of 2026-07-28 are covered request by request in test_mcp_contract. What was missing is everything that only appears once both eras share one server. A handshake-era exchange is now walked end to end over both transports and every response is scanned recursively for a modern key rather than compared against a byte golden that would break on any harmless reordering, and each result's top-level key set is pinned: a client of the older protocol validates against a schema that knows those keys and no others. The concurrency tests start from a barrier rather than looping: two HTTP clients that both use id 1 must each read their own file back, a legacy and a modern client asking for the same catalog must each get the shape they asked for, two patches to one line must leave the file as one of them wrote it and never a mix, two threads racing the first command must agree on the runtime tree, and the non-git diff fallback must survive a patch landing beside it. The official python SDK is the only client here we did not write, so the dev extra installs it and a smoke test drives both transports with it. The SDK probes server/discover, is answered -32601, and falls back on its own; the test asserts the server is usable and deliberately not that the result is shaped for the new era, which is what enabling discover will change. A missing SDK fails in CI and skips loudly elsewhere: a silent skip would remove the one independent reading we have. Co-authored-by: Cursor --- CHANGELOG.md | 3 + COMPLIANCE.md | 1 + Makefile | 7 +- pyproject.toml | 3 + tests/compliance/runner.py | 1 + tests/compliance/test_dual_era.py | 535 ++++++++++++++++++++++++++++++ 6 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 tests/compliance/test_dual_era.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6511fd5..b96b81d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,9 @@ failure reports `RUNTIME_DIR_UNWRITABLE` instead of moving a running command's directories, and the non-git diff fallback snapshots its patch baselines under the patch lock. +- The `dev` extra now installs the official MCP python SDK (`mcp`). The + compliance suite drives this server with it over both transports, which is + the only check in the suite that does not use a client we wrote ourselves. - Telemetry now measures the process rather than one client's handshake. A session is activated by the first request or notification that passes envelope validation — in either era, and before the method runs, so a first diff --git a/COMPLIANCE.md b/COMPLIANCE.md index aa2d625..5cbca2a 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -34,6 +34,7 @@ It adds lint, typecheck, unittest discovery, required docs checks, schema-drift ```bash make test-mcp-contract +make test-dual-era make test-tool-golden make test-security make test-e2e diff --git a/Makefile b/Makefile index 79ba716..dd6d1d9 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ DESKTOP_PACKAGE := apps/desktop-client/mcp_desktop_client DESKTOP_TS := $(DESKTOP_PACKAGE)/locales/app_zh_CN.ts DESKTOP_QM := $(DESKTOP_PACKAGE)/locales/app_zh_CN.qm -.PHONY: start lint typecheck test ci check-dispatch-inputs check-npm-launcher check-release compliance test-protocol test-integration test-mcp-contract test-tool-golden test-security test-e2e test-runtime-semantics test-docs-required test-schema-drift dogfood-mcp dogfood-runner dogfood-smoke benchmark-latency benchmark-smoke benchmark-real-workloads swebench-reference-predictions swebench-preflight swebench-evaluate desktop-i18n-update desktop-i18n-release desktop-i18n-check install-user publish-testpypi publish-pypi publish-all report +.PHONY: start lint typecheck test ci check-dispatch-inputs check-npm-launcher check-release compliance test-protocol test-integration test-mcp-contract test-dual-era test-tool-golden test-security test-e2e test-runtime-semantics test-docs-required test-schema-drift dogfood-mcp dogfood-runner dogfood-smoke benchmark-latency benchmark-smoke benchmark-real-workloads swebench-reference-predictions swebench-preflight swebench-evaluate desktop-i18n-update desktop-i18n-release desktop-i18n-check install-user publish-testpypi publish-pypi publish-all report start: PYTHONDONTWRITEBYTECODE=1 $(PYTHON) -m coding_tools_mcp --workspace "$(MCP_WORKSPACE)" --host "$(MCP_HOST)" --port "$(MCP_PORT)" $(MCP_ARGS) @@ -51,11 +51,14 @@ compliance: test-protocol: test-mcp-contract -test-integration: test-tool-golden test-security test-e2e test-runtime-semantics +test-integration: test-dual-era test-tool-golden test-security test-e2e test-runtime-semantics test-mcp-contract: $(COMPLIANCE_RUNNER) --suite mcp-contract $(REPORT_FLAG) +test-dual-era: + $(COMPLIANCE_RUNNER) --suite dual-era $(REPORT_FLAG) + test-tool-golden: $(COMPLIANCE_RUNNER) --suite tool-golden $(REPORT_FLAG) diff --git a/pyproject.toml b/pyproject.toml index e333f8c..f5e949b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ Issues = "https://github.com/xyTom/coding-tools-mcp/issues" [project.optional-dependencies] dev = [ + # The official MCP SDK is the only client in the test suite we did not + # write; the dual-era smoke needs it installed to run at all. + "mcp>=2.0", "mypy>=2.1,<2.2", "PyYAML>=6.0", "ruff>=0.15,<0.16", diff --git a/tests/compliance/runner.py b/tests/compliance/runner.py index b91d83e..b646a92 100644 --- a/tests/compliance/runner.py +++ b/tests/compliance/runner.py @@ -22,6 +22,7 @@ SUITES = { "mcp-contract": ["tests.compliance.test_mcp_contract"], + "dual-era": ["tests.compliance.test_dual_era"], "tool-golden": ["tests.compliance.test_tool_golden"], "security": ["tests.compliance.test_security"], "e2e": ["tests.compliance.test_e2e"], diff --git a/tests/compliance/test_dual_era.py b/tests/compliance/test_dual_era.py new file mode 100644 index 0000000..c7fd375 --- /dev/null +++ b/tests/compliance/test_dual_era.py @@ -0,0 +1,535 @@ +"""Dual-era compliance: what the two protocol eras owe each other. + +The precise per-error paths of `2026-07-28` live in `test_mcp_contract`. This +suite covers what only shows up once both eras share one server: that a +handshake-era response is still shaped exactly as it was, that concurrent +clients of either era reach neither into each other's answers nor into the +workspace state they share, and that the official python SDK — the one client +we did not write — can drive this server over both transports. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import queue +import shutil +import subprocess +import sys +import threading +import unittest +import urllib.error +import urllib.request +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +from coding_tools_mcp.protocol import KNOWN_PROTOCOL_VERSIONS +from coding_tools_mcp.server import Runtime +from tests.compliance.fixtures import FixtureWorkspace, workspace_from_fixture +from tests.compliance.mcp_client import prepend_repo_pythonpath, safe_server_env +from tests.compliance.test_support import ComplianceTestCase, structured_payload + + +LEGACY_PROTOCOL_VERSION = "2025-11-25" +MODERN_PROTOCOL_VERSION = "2026-07-28" +META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion" +META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities" +MODERN_META_PREFIX = "io.modelcontextprotocol/" +MODERN_RESULT_FIELDS = ("resultType", "ttlMs", "cacheScope") + +# What a handshake-era result contains, exactly: a client of the older +# protocol validates against a schema that knows these keys and no others. +LEGACY_RESULT_KEYS = { + "initialize": {"protocolVersion", "capabilities", "serverInfo", "instructions"}, + "tools/list": {"tools"}, + "tools/call": {"content", "structuredContent", "isError"}, + "ping": set(), +} +# How a patch that lost the race is allowed to fail: the context it was +# written against is gone, or the committer caught the file changing under it. +CONFLICT_ERROR_CODES = { + "PATCH_CONTEXT_NOT_FOUND", + "PATCH_CONTEXT_AMBIGUOUS", + "PATCH_CONFLICT", +} +SDK_TIMEOUT_SECONDS = 60.0 +STDIO_READ_TIMEOUT_SECONDS = 15.0 +RACE_TIMEOUT_SECONDS = 60.0 + + +def legacy_request(request_id: Any, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}} + + +def modern_request(request_id: Any, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + body = dict(params or {}) + body["_meta"] = { + META_PROTOCOL_VERSION: MODERN_PROTOCOL_VERSION, + META_CLIENT_CAPABILITIES: {}, + } + return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": body} + + +def modern_headers(request: dict[str, Any]) -> dict[str, str]: + """The headers SEP-2243 has a modern request mirror its body with.""" + + method = str(request["method"]) + headers = {"MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, "Mcp-Method": method} + name = request.get("params", {}).get("name") + if method == "tools/call" and isinstance(name, str): + headers["Mcp-Name"] = name + return headers + + +def http_rpc( + url: str, + payload: dict[str, Any], + *, + headers: dict[str, str] | None = None, + timeout: float = 20.0, +) -> tuple[int, dict[str, Any]]: + """POST one JSON-RPC message and return the status with the raw envelope.""" + + sent = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} + sent.update(headers or {"MCP-Protocol-Version": LEGACY_PROTOCOL_VERSION}) + request = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=sent, method="POST") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = response.read().decode("utf-8") + return response.status, json.loads(body) if body else {} + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + return exc.code, json.loads(body) if body else {} + + +def modern_field_paths(node: Any, path: str = "response") -> list[str]: + """Every place a modern-only key hides in a response, however deep.""" + + found: list[str] = [] + if isinstance(node, dict): + for key, value in node.items(): + here = f"{path}.{key}" + if key in MODERN_RESULT_FIELDS or str(key).startswith(MODERN_META_PREFIX): + found.append(here) + found.extend(modern_field_paths(value, here)) + elif isinstance(node, list): + for index, item in enumerate(node): + found.extend(modern_field_paths(item, f"{path}[{index}]")) + return found + + +def run_in_barrier(workers: dict[str, Callable[[], Any]], *, timeout: float = RACE_TIMEOUT_SECONDS) -> dict[str, Any]: + """Run callables from a synchronized start and return what each produced. + + A barrier, not a stress loop: the window worth testing is the one where + both threads are inside the same code at the same time, and a loop only + finds it by accident. + """ + + barrier = threading.Barrier(len(workers), timeout=timeout) + outcomes: dict[str, Any] = {} + errors: dict[str, BaseException] = {} + + def run(tag: str, work: Callable[[], Any]) -> None: + try: + barrier.wait() + outcomes[tag] = work() + except BaseException as exc: # noqa: BLE001 - reported on the test thread instead + errors[tag] = exc + + threads = [threading.Thread(target=run, args=(tag, work), name=f"race-{tag}") for tag, work in workers.items()] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=timeout) + if thread.is_alive(): + raise AssertionError(f"worker {thread.name} did not finish within {timeout}s") + if errors: + raise AssertionError(f"concurrent workers raised: {errors!r}") + return outcomes + + +class StdioConnection: + """A raw newline-delimited JSON-RPC pipe that performs no handshake of its own.""" + + def __init__(self, workspace: Path) -> None: + self.workspace = workspace + self.process: subprocess.Popen[str] | None = None + self._responses: queue.Queue[str] = queue.Queue() + self._stderr: list[str] = [] + + def __enter__(self) -> StdioConnection: + self.process = subprocess.Popen( + [sys.executable, "-m", "coding_tools_mcp", "--workspace", str(self.workspace), "--stdio"], + cwd=str(self.workspace), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=prepend_repo_pythonpath(os.environ.copy()), + text=True, + start_new_session=True, + ) + threading.Thread(target=self._drain_stdout, daemon=True).start() + threading.Thread(target=self._drain_stderr, daemon=True).start() + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + process = self.process + if process is None: + return + if process.stdin is not None: + try: + process.stdin.close() + except OSError: + pass + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + for stream in (process.stdout, process.stderr): + if stream is not None: + stream.close() + + def _drain_stdout(self) -> None: + process = self.process + if process is None or process.stdout is None: + return + for line in process.stdout: + self._responses.put(line) + + def _drain_stderr(self) -> None: + process = self.process + if process is None or process.stderr is None: + return + for line in process.stderr: + self._stderr.append(line) + + def request(self, payload: dict[str, Any]) -> dict[str, Any]: + process = self.process + assert process is not None and process.stdin is not None + process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + process.stdin.flush() + try: + line = self._responses.get(timeout=STDIO_READ_TIMEOUT_SECONDS) + except queue.Empty as exc: + raise AssertionError( + f"no stdio response for {payload.get('method')!r}; stderr={''.join(self._stderr)[-2000:]!r}" + ) from exc + return json.loads(line) + + +@contextmanager +def scratch_runtime(*, git: bool = True) -> Iterator[tuple[FixtureWorkspace, Runtime]]: + """An in-process runtime over a throwaway copy of the fixture workspace.""" + + with workspace_from_fixture("tiny-js-project", git=git) as workspace: + runtime = Runtime(workspace.root) + try: + yield workspace, runtime + finally: + runtime.close() + + +def update_patch(path: str, old_line: str, new_line: str) -> str: + return f"*** Begin Patch\n*** Update File: {path}\n@@\n-{old_line}\n+{new_line}\n*** End Patch\n" + + +class LegacyShapeTests(ComplianceTestCase): + """A handshake-era client must not be able to tell that the new era exists.""" + + def test_http_legacy_exchange_carries_no_modern_field(self) -> None: + url = str(self.client.url) + for method, params, expectation in legacy_script(): + with self.subTest(transport="http", method=method, expectation=expectation): + status, response = http_rpc(url, legacy_request(1, method, params)) + self.assertEqual(status, 200, f"handshake-era responses stay 200: {response!r}") + self.assert_legacy_envelope(method, response, expectation) + + def test_stdio_legacy_exchange_carries_no_modern_field(self) -> None: + with StdioConnection(self.workspace.root) as connection: + for method, params, expectation in legacy_script(): + with self.subTest(transport="stdio", method=method, expectation=expectation): + response = connection.request(legacy_request(1, method, params)) + self.assert_legacy_envelope(method, response, expectation) + + def assert_legacy_envelope(self, method: str, response: dict[str, Any], expectation: str) -> None: + self.assertEqual(response.get("jsonrpc"), "2.0", response) + self.assertEqual(response.get("id"), 1, response) + self.assertEqual( + modern_field_paths(response), + [], + f"a handshake-era response must carry no modern field: {response!r}", + ) + if expectation == "rpc_error": + self.assertNotIn("result", response) + self.assertEqual(response.get("error", {}).get("code"), -32601, response) + return + result = response.get("result") + self.assertIsInstance(result, dict, response) + self.assertEqual(set(result), LEGACY_RESULT_KEYS[method], f"{method} result keys drifted: {result!r}") + if method == "initialize": + self.assertEqual(result["protocolVersion"], LEGACY_PROTOCOL_VERSION) + if method == "tools/call": + self.assertEqual(result["isError"], expectation == "tool_error", result) + + +def legacy_script() -> list[tuple[str, dict[str, Any], str]]: + """One pass over everything a handshake-era client actually sends.""" + + return [ + ( + "initialize", + { + "protocolVersion": LEGACY_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "dual-era-legacy", "version": "1.0"}, + }, + "result", + ), + ("tools/list", {}, "result"), + ("tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}, "result"), + ("tools/call", {"name": "read_file", "arguments": {"path": "no-such-file.txt"}}, "tool_error"), + ("ping", {}, "result"), + ("resources/read", {"uri": "file:///nope"}, "rpc_error"), + ] + + +class ConcurrentClientTests(ComplianceTestCase): + """Two clients, one server, one workspace: no crossed wires.""" + + def test_two_http_clients_both_using_id_one_get_their_own_answer(self) -> None: + url = str(self.client.url) + for tag in ("alpha", "beta"): + (self.workspace.root / f"{tag}.txt").write_text(f"{tag}-marker\n", encoding="utf-8") + + def read(path: str) -> tuple[int, dict[str, Any]]: + return http_rpc(url, legacy_request(1, "tools/call", {"name": "read_file", "arguments": {"path": path}})) + + outcomes = run_in_barrier({tag: (lambda tag=tag: read(f"{tag}.txt")) for tag in ("alpha", "beta")}) + + for tag in ("alpha", "beta"): + with self.subTest(client=tag): + status, response = outcomes[tag] + self.assertEqual(status, 200) + self.assertEqual(response.get("id"), 1) + payload = structured_payload(response["result"]) + self.assertEqual(payload.get("path"), f"{tag}.txt", payload) + self.assertIn(f"{tag}-marker", str(payload.get("content"))) + + def test_a_legacy_and_a_modern_client_each_get_the_shape_they_asked_for(self) -> None: + url = str(self.client.url) + + def legacy_list() -> tuple[int, dict[str, Any]]: + return http_rpc(url, legacy_request(1, "tools/list")) + + def modern_list() -> tuple[int, dict[str, Any]]: + request = modern_request(1, "tools/list") + return http_rpc(url, request, headers=modern_headers(request)) + + outcomes = run_in_barrier({"legacy": legacy_list, "modern": modern_list}) + + legacy_status, legacy_response = outcomes["legacy"] + self.assertEqual(legacy_status, 200) + self.assertEqual(set(legacy_response["result"]), {"tools"}) + self.assertEqual(modern_field_paths(legacy_response), [], legacy_response) + + modern_status, modern_response = outcomes["modern"] + self.assertEqual(modern_status, 200) + modern_result = modern_response["result"] + self.assertEqual(modern_result.get("resultType"), "complete", modern_result) + self.assertEqual(modern_result.get("ttlMs"), 0) + self.assertEqual(modern_result.get("cacheScope"), "private") + self.assertEqual( + [tool["name"] for tool in modern_result["tools"]], + [tool["name"] for tool in legacy_response["result"]["tools"]], + "both eras are served one catalog", + ) + + +class WorkspaceRaceTests(unittest.TestCase): + """Deterministic races against the state one runtime shares between clients.""" + + def test_two_patches_to_one_region_leave_a_whole_file_behind(self) -> None: + old_line = " return a - b;" + replacements = {"alpha": " return a + b;", "beta": " return b - a;"} + with scratch_runtime() as (workspace, runtime): + target = workspace.root / "src" / "math.js" + original = target.read_text(encoding="utf-8") + self.assertIn(old_line, original) + + def patch(tag: str) -> dict[str, Any]: + return runtime.call_tool( + "apply_patch", {"patch": update_patch("src/math.js", old_line, replacements[tag])} + ) + + outcomes = run_in_barrier({tag: (lambda tag=tag: patch(tag)) for tag in replacements}) + final = target.read_text(encoding="utf-8") + + winners = [tag for tag, result in outcomes.items() if not result.get("isError")] + self.assertEqual( + len(winners), + 1, + f"two edits of one line cannot both apply, and neither may be lost: {outcomes!r}", + ) + loser = next(tag for tag in replacements if tag not in winners) + failure = structured_payload(outcomes[loser]).get("error", {}) + self.assertIn(failure.get("code"), CONFLICT_ERROR_CODES, outcomes[loser]) + self.assertEqual( + final, + original.replace(old_line, replacements[winners[0]]), + "the file must be exactly what the winning patch produces", + ) + + def test_two_threads_racing_the_first_command_agree_on_the_runtime_tree(self) -> None: + directory_keys = ("runtime_dir", "home", "tmpdir", "cache_dir") + with scratch_runtime() as (_workspace, runtime): + + def first_command(tag: str) -> dict[str, Any]: + started = runtime.call_tool( + "exec_command", + {"cmd": f"printf '{tag}'", "timeout_ms": 10000, "yield_time_ms": 5000}, + ) + environment = runtime.call_tool("check_exec_environment", {}) + return {"command": structured_payload(started), "environment": structured_payload(environment)} + + outcomes = run_in_barrier({tag: (lambda tag=tag: first_command(tag)) for tag in ("first", "second")}) + + for tag, outcome in outcomes.items(): + with self.subTest(worker=tag): + self.assertEqual(outcome["command"].get("exit_code"), 0, outcome) + self.assertEqual(outcome["command"].get("stdout", tag), tag, outcome) + directories = [{key: outcome["environment"][key] for key in directory_keys} for outcome in outcomes.values()] + self.assertEqual( + directories[0], + directories[1], + "the runtime tree must not move under a command that is already running", + ) + self.assertEqual( + directories[0], + { + "runtime_dir": str(runtime.runtime_dir), + "home": str(runtime.home_dir), + "tmpdir": str(runtime.tmp_dir), + "cache_dir": str(runtime.cache_dir), + }, + ) + + def test_the_non_git_diff_fallback_survives_a_concurrent_patch(self) -> None: + if shutil.which("git") is None: + self.skipTest("git is not available") + with scratch_runtime(git=False) as (_workspace, runtime): + # The fallback diffs against the baselines apply_patch records, so + # there has to be one before the race is worth running. + seed = runtime.call_tool( + "apply_patch", {"patch": update_patch("src/math.js", " return a - b;", " return a + b;")} + ) + self.assertFalse(seed.get("isError"), seed) + + add = "*** Begin Patch\n*** Add File: notes/race.md\n+raced\n*** End Patch\n" + outcomes = run_in_barrier( + { + "patch": lambda: runtime.call_tool("apply_patch", {"patch": add}), + "diff": lambda: runtime.call_tool("git_diff", {}), + } + ) + + self.assertFalse(outcomes["patch"].get("isError"), outcomes["patch"]) + diff_payload = structured_payload(outcomes["diff"]) + self.assertFalse(outcomes["diff"].get("isError"), diff_payload) + self.assertIn("non-git diff fallback", diff_payload.get("warnings", [])) + self.assertIn("return a + b;", diff_payload.get("diff", "")) + + +def require_official_sdk(test: unittest.TestCase) -> None: + """Load the official SDK, and refuse to let CI pass without it. + + This is the only check here that is not written against a client of our + own, so a silent skip in CI would quietly remove the one independent + reading of what this server does. + """ + + try: + import mcp # noqa: F401 + except ImportError as exc: + message = ( + f"the official MCP python SDK is not importable ({exc}); " + "install it with `pip install -e '.[dev]'`" + ) + if os.environ.get("CI"): + test.fail(f"CI must run the official SDK smoke: {message}") + print(f"SKIP: {message}", file=sys.stderr, flush=True) + test.skipTest(message) + + +async def sdk_smoke(transport: Any) -> dict[str, Any]: + """Connect, negotiate, list the tools, and call one cheap read-only tool. + + The SDK probes `server/discover` first and, until that method is enabled, + is answered with `-32601` and falls back to the handshake era on its own. + So this reports only that the server was usable and never asserts a + `2026-07-28` result shape; those assertions belong with the switch that + turns discover on. + """ + + from mcp import Client + + async with Client(transport, raise_exceptions=True) as client: + listed = await client.list_tools() + result = await client.call_tool("check_exec_environment", {}) + return { + "protocol_version": client.protocol_version, + "server_name": getattr(client.server_info, "name", None), + "tools": sorted(tool.name for tool in listed.tools), + "is_error": bool(result.is_error), + "content": [type(item).__name__ for item in result.content], + } + + +def run_sdk_smoke(transport: Any) -> dict[str, Any]: + """Run one smoke exchange under a timeout, so a stall fails instead of hanging.""" + + async def bounded() -> dict[str, Any]: + return await asyncio.wait_for(sdk_smoke(transport), timeout=SDK_TIMEOUT_SECONDS) + + return asyncio.run(bounded()) + + +def assert_sdk_smoke(test: unittest.TestCase, summary: dict[str, Any]) -> None: + test.assertIn(summary["protocol_version"], KNOWN_PROTOCOL_VERSIONS, summary) + test.assertEqual(summary["server_name"], "coding-tools-mcp", summary) + test.assertIn("check_exec_environment", summary["tools"]) + test.assertGreaterEqual(len(summary["tools"]), 18, summary) + test.assertFalse(summary["is_error"], summary) + test.assertTrue(summary["content"], summary) + + +class OfficialSDKStdioSmokeTests(unittest.TestCase): + def test_the_official_sdk_can_drive_the_stdio_server(self) -> None: + require_official_sdk(self) + from mcp import StdioServerParameters + from mcp.client.stdio import stdio_client + + with workspace_from_fixture("tiny-js-project", git=False) as workspace: + parameters = StdioServerParameters( + command=sys.executable, + args=["-m", "coding_tools_mcp", "--workspace", str(workspace.root), "--stdio"], + cwd=str(workspace.root), + env=safe_server_env(), + ) + summary = run_sdk_smoke(stdio_client(parameters)) + + assert_sdk_smoke(self, summary) + + +class OfficialSDKHttpSmokeTests(ComplianceTestCase): + def test_the_official_sdk_can_drive_the_http_server(self) -> None: + require_official_sdk(self) + assert_sdk_smoke(self, run_sdk_smoke(str(self.client.url))) + + +if __name__ == "__main__": + unittest.main() From 6879cd54f27e2e49c9411759dc9f7be94f9d7e8c Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:56:11 +0000 Subject: [PATCH 16/25] Enable server/discover for stateless clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 2026-07-28 client probes with server/discover instead of handshaking, so until it is answered the dual-era clients this release exists for keep falling back to the legacy handshake. The runtime returns the business fields — the versions it speaks per request, the tools capability, and the workspace instructions — and the modern encoder adds the result envelope, including the conservative cache hints the instructions require: they quote the workspace's own instruction files, so the result is never shared and never reused. A probe that carries no modern _meta stays unknown. Answering it would mean guessing an era for a client that named none, and the -32601 it gets is what sends it to initialize, a path that works. Co-authored-by: Cursor --- CHANGELOG.md | 15 +++++- coding_tools_mcp/protocol.py | 20 +++++--- coding_tools_mcp/server.py | 18 +++++++ coding_tools_mcp/transport_stdio.py | 2 + tests/compliance/test_dual_era.py | 63 ++++++++++++++++++++--- tests/compliance/test_mcp_contract.py | 72 +++++++++++++++++++++++++-- 6 files changed, 171 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b96b81d..c881678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,13 +119,24 @@ Such a request states its own protocol version in `params._meta` (`io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` are required, - `io.modelcontextprotocol/clientInfo` is optional) and may call `ping`, - `tools/list`, and `tools/call` immediately. A `_meta` version this server + `io.modelcontextprotocol/clientInfo` is optional) and may call + `server/discover`, `ping`, `tools/list`, and `tools/call` immediately. A + `_meta` version this server does not speak is answered with `-32022` and the versions it does (`data.supported`); a missing or mistyped required `_meta` field is answered with `-32602`. Requests without that `_meta` key — including legacy requests that carry `_meta.progressToken`, and every `initialize` — keep the handshake behavior they had. +- `server/discover` answers the probe a `2026-07-28` client sends instead of a + handshake, so such a client never has to send one: it reports the versions + this server speaks per request (`["2026-07-28"]` alone, since naming a + handshake-era version here would invite the client to put one in its + `_meta`, where it is unsupported), the `tools` capability, and the same + workspace instructions `initialize` returns. Those instructions quote the + workspace's own instruction files, so the result carries `ttlMs: 0` and + `cacheScope: "private"` as `tools/list` does. A probe that states no + protocol version in `_meta` is a handshake-era request and is still answered + with `-32601`, which is what sends such a client to `initialize`. - Streamable HTTP serves `2026-07-28` as well, with the mirror headers SEP-2243 requires. Such a request must repeat its `_meta` protocol version in `MCP-Protocol-Version` and its method in `Mcp-Method`; `tools/call`, diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index 1e54c71..f6b6f6a 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -41,19 +41,19 @@ BASE64_SENTINEL_PREFIX = "=?base64?" BASE64_SENTINEL_SUFFIX = "?=" +# The method a dual-era client probes with before it decides to handshake. +DISCOVER_METHOD = "server/discover" MODERN_METHODS = frozenset( { + DISCOVER_METHOD, "notifications/cancelled", "ping", "tools/list", "tools/call", } ) -MODERN_CACHEABLE_METHODS = frozenset({"tools/list"}) +MODERN_CACHEABLE_METHODS = frozenset({DISCOVER_METHOD, "tools/list"}) MODERN_RESULT_TYPE = "complete" -# The method a dual-era client probes with before it decides to handshake. -# Not implemented yet, but named here because the probe is worth counting. -DISCOVER_METHOD = "server/discover" @dataclass(frozen=True) @@ -321,8 +321,9 @@ def shape_result( shaped["_meta"] = meta if method in MODERN_CACHEABLE_METHODS: # A catalog is shaped by the workspace and the permission mode it was - # served under, so the conservative defaults apply: never shared, - # never reused. + # served under, and a discover result quotes the workspace's own + # instruction files, so the conservative defaults apply to both: + # never shared, never reused. shaped["ttlMs"] = 0 shaped["cacheScope"] = "private" return shaped @@ -394,6 +395,8 @@ def _dispatch_modern( return None if method == "ping": return {} + if method == DISCOVER_METHOD: + return runtime.discover_payload() if method == "tools/list": return runtime.list_tools() return _call_tool(runtime, params, context) @@ -413,7 +416,10 @@ def _dispatch_legacy( method is served whether or not the client handshook first. ``initialize`` is therefore idempotent — it negotiates a version and answers with it as often as it is asked, which is what a connector that probes, falls back, - and handshakes again needs. Returns None for a notification. + and handshakes again needs. ``server/discover`` is deliberately absent: it + answers for the modern era only, so a probe that states no protocol version + is unknown here and the client falls back to the handshake, which is a + working path rather than a failure. Returns None for a notification. """ if method == "initialize": diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index f3459c5..85693e3 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -72,6 +72,7 @@ KNOWN_PROTOCOL_VERSIONS, LATEST_LEGACY_PROTOCOL_VERSION, MODERN_ERA, + MODERN_PROTOCOL_VERSIONS, UNSUPPORTED_PROTOCOL_VERSION, RequestContext, dispatch_rpc, @@ -1478,6 +1479,23 @@ def initialize_result(self, protocol_version: str = LATEST_LEGACY_PROTOCOL_VERSI "instructions": self.project_context.server_instructions(), } + def discover_payload(self) -> dict[str, Any]: + """Tell a client that never handshakes what this server can do. + + The modern counterpart of the handshake result, minus everything the + handshake only needed because it was a handshake: no version is + negotiated here, so the versions this server speaks per request are + listed instead, and only those — a legacy version named here would + invite a client to put one in its ``_meta``. The encoder adds the + result envelope, so the fields returned are the answer itself. + """ + + return { + "supportedVersions": list(MODERN_PROTOCOL_VERSIONS), + "capabilities": {"tools": {"listChanged": False}}, + "instructions": self.project_context.server_instructions(), + } + def server_identity(self) -> dict[str, Any]: """Name this server for the handshake and for modern result metadata. diff --git a/coding_tools_mcp/transport_stdio.py b/coding_tools_mcp/transport_stdio.py index 82f5d0d..1a64e43 100644 --- a/coding_tools_mcp/transport_stdio.py +++ b/coding_tools_mcp/transport_stdio.py @@ -19,6 +19,8 @@ def initialize( def initialize_result(self, protocol_version: str = ...) -> dict[str, Any]: ... + def discover_payload(self) -> dict[str, Any]: ... + def server_identity(self) -> dict[str, Any]: ... def list_tools(self) -> dict[str, Any]: ... diff --git a/tests/compliance/test_dual_era.py b/tests/compliance/test_dual_era.py index c7fd375..d67ba46 100644 --- a/tests/compliance/test_dual_era.py +++ b/tests/compliance/test_dual_era.py @@ -26,7 +26,6 @@ from pathlib import Path from typing import Any -from coding_tools_mcp.protocol import KNOWN_PROTOCOL_VERSIONS from coding_tools_mcp.server import Runtime from tests.compliance.fixtures import FixtureWorkspace, workspace_from_fixture from tests.compliance.mcp_client import prepend_repo_pythonpath, safe_server_env @@ -37,6 +36,7 @@ MODERN_PROTOCOL_VERSION = "2026-07-28" META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion" META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities" +META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" MODERN_META_PREFIX = "io.modelcontextprotocol/" MODERN_RESULT_FIELDS = ("resultType", "ttlMs", "cacheScope") @@ -158,6 +158,7 @@ class StdioConnection: def __init__(self, workspace: Path) -> None: self.workspace = workspace self.process: subprocess.Popen[str] | None = None + self.methods_sent: list[str] = [] self._responses: queue.Queue[str] = queue.Queue() self._stderr: list[str] = [] @@ -211,6 +212,7 @@ def _drain_stderr(self) -> None: def request(self, payload: dict[str, Any]) -> dict[str, Any]: process = self.process assert process is not None and process.stdin is not None + self.methods_sent.append(str(payload.get("method"))) process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") process.stdin.flush() try: @@ -298,6 +300,47 @@ def legacy_script() -> list[tuple[str, dict[str, Any], str]]: ] +class ModernLifecycleTests(unittest.TestCase): + """The new era from first byte to last: one process, no handshake in it.""" + + def test_a_client_that_discovers_never_needs_to_initialize(self) -> None: + with workspace_from_fixture("tiny-js-project", git=False) as workspace: + with StdioConnection(workspace.root) as connection: + discovered = connection.request(modern_request(1, "server/discover")) + discovery = self.assert_modern_result(discovered) + self.assertEqual(discovery.get("supportedVersions"), [MODERN_PROTOCOL_VERSION]) + self.assertEqual(discovery.get("capabilities"), {"tools": {"listChanged": False}}) + self.assertTrue(discovery.get("instructions"), discovery) + self.assertEqual(discovery.get("ttlMs"), 0) + self.assertEqual(discovery.get("cacheScope"), "private") + + listed = self.assert_modern_result(connection.request(modern_request(2, "tools/list"))) + self.assertTrue({tool["name"] for tool in listed["tools"]} >= {"read_file"}) + + called = self.assert_modern_result( + connection.request( + modern_request(3, "tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}) + ) + ) + self.assertFalse(called.get("isError", False), called) + self.assertEqual(structured_payload(called).get("path"), "src/math.js") + + self.assertNotIn("initialize", connection.methods_sent) + + def assert_modern_result(self, response: dict[str, Any]) -> dict[str, Any]: + self.assertNotIn("error", response, response) + result = response.get("result") + self.assertIsInstance(result, dict, response) + assert isinstance(result, dict) + self.assertEqual(result.get("resultType"), "complete", result) + self.assertEqual( + result.get("_meta", {}).get(META_SERVER_INFO, {}).get("name"), + "coding-tools-mcp", + result, + ) + return result + + class ConcurrentClientTests(ComplianceTestCase): """Two clients, one server, one workspace: no crossed wires.""" @@ -468,11 +511,9 @@ def require_official_sdk(test: unittest.TestCase) -> None: async def sdk_smoke(transport: Any) -> dict[str, Any]: """Connect, negotiate, list the tools, and call one cheap read-only tool. - The SDK probes `server/discover` first and, until that method is enabled, - is answered with `-32601` and falls back to the handshake era on its own. - So this reports only that the server was usable and never asserts a - `2026-07-28` result shape; those assertions belong with the switch that - turns discover on. + The SDK probes `server/discover` before it considers a handshake, so what + it reports back is also the verdict on that answer: a client we did not + write read our discover result and settled on the era it describes. """ from mcp import Client @@ -480,9 +521,12 @@ async def sdk_smoke(transport: Any) -> dict[str, Any]: async with Client(transport, raise_exceptions=True) as client: listed = await client.list_tools() result = await client.call_tool("check_exec_environment", {}) + tools_capability = getattr(client.server_capabilities, "tools", None) return { "protocol_version": client.protocol_version, "server_name": getattr(client.server_info, "name", None), + "instructions": client.instructions or "", + "tools_capability": None if tools_capability is None else tools_capability.list_changed, "tools": sorted(tool.name for tool in listed.tools), "is_error": bool(result.is_error), "content": [type(item).__name__ for item in result.content], @@ -499,8 +543,13 @@ async def bounded() -> dict[str, Any]: def assert_sdk_smoke(test: unittest.TestCase, summary: dict[str, Any]) -> None: - test.assertIn(summary["protocol_version"], KNOWN_PROTOCOL_VERSIONS, summary) + # Anything less than the modern version means the SDK read our discover + # result and went back to the handshake anyway, which is the failure this + # smoke exists to catch. + test.assertEqual(summary["protocol_version"], MODERN_PROTOCOL_VERSION, summary) test.assertEqual(summary["server_name"], "coding-tools-mcp", summary) + test.assertEqual(summary["tools_capability"], False, summary) + test.assertIn("inside the configured workspace", summary["instructions"], summary) test.assertIn("check_exec_environment", summary["tools"]) test.assertGreaterEqual(len(summary["tools"]), 18, summary) test.assertFalse(summary["is_error"], summary) diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index eaa6e7f..4780869 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -577,8 +577,26 @@ def test_http_modern_name_header_is_required_by_method_name(self) -> None: self.assertEqual(missing_status, 400, missing) self.assertEqual(missing.get("error", {}).get("code"), -32020) + def test_http_modern_discover_answers_without_a_name_header(self) -> None: + """Discover names no subject, so the mirror is version and method only.""" + + request = modern_request(1, "server/discover") + status, response = self.modern_http_post(request) + self.assertEqual(status, 200, response) + result = response.get("result", {}) + self.assert_modern_result(result) + self.assertEqual(result.get("supportedVersions"), [MODERN_PROTOCOL_VERSION]) + self.assertEqual(result.get("capabilities"), {"tools": {"listChanged": False}}) + self.assertTrue(result.get("instructions")) + self.assertEqual(result.get("ttlMs"), 0) + self.assertEqual(result.get("cacheScope"), "private") + + missing_method_status, missing_method = self.modern_http_post(request, drop=("Mcp-Method",)) + self.assertEqual(missing_method_status, 400, missing_method) + self.assertEqual(missing_method.get("error", {}).get("code"), -32020) + def test_http_modern_protocol_errors_map_to_http_statuses(self) -> None: - unknown_status, unknown = self.modern_http_post(modern_request(1, "server/discover")) + unknown_status, unknown = self.modern_http_post(modern_request(1, "prompts/list")) self.assertEqual(unknown_status, 404, unknown) self.assertEqual(unknown.get("error", {}).get("code"), -32601) @@ -600,7 +618,8 @@ def test_http_modern_protocol_errors_map_to_http_statuses(self) -> None: ) # A handshake client reads only the JSON-RPC error, and mapping its - # errors onto statuses now would break it. + # errors onto statuses now would break it. A probe that states no + # protocol version is such a client, discover being a modern method. legacy_status, legacy = self.raw_http_post( b'{"jsonrpc":"2.0","id":4,"method":"server/discover","params":{}}' ) @@ -1476,8 +1495,55 @@ def test_stdio_modern_meta_is_validated_before_the_method_runs(self) -> None: def test_stdio_modern_era_still_reports_unimplemented_methods(self) -> None: process = self.start_stdio_server() try: - probe = self.stdio_rpc_allow_error(process, modern_request("discover-probe", "server/discover")) + probe = self.stdio_rpc_allow_error(process, modern_request(1, "prompts/list")) + self.assertEqual(probe.get("error", {}).get("code"), -32601) + self.assertIsNone(process.poll(), "an unsupported method must not end the stdio session") + finally: + self.stop_process(process) + + def test_stdio_modern_discover_describes_the_server_to_a_client_that_never_handshakes(self) -> None: + """The probe that replaces the handshake, answered in full.""" + + instructions_file = "Fixture instructions: prefer npm test over ad-hoc runs." + (self.workspace.root / "AGENTS.md").write_text(f"{instructions_file}\n", encoding="utf-8") + process = self.start_stdio_server() + try: + discovered = self.stdio_rpc(process, modern_request("discover-probe", "server/discover")) + result = discovered.get("result", {}) + self.assert_modern_result(result) + # Only the modern version: a legacy version offered here would + # invite the client to send one back in _meta, where it is + # unsupported. + self.assertEqual(result.get("supportedVersions"), [MODERN_PROTOCOL_VERSION]) + self.assertEqual(result.get("capabilities"), {"tools": {"listChanged": False}}) + # The workspace's own instruction files travel in the answer, which + # is why the result may never be cached or shared. + instructions = result.get("instructions") + self.assertIsInstance(instructions, str) + self.assertTrue(instructions) + self.assertIn("only for coding operations inside the configured workspace", instructions) + self.assertIn(instructions_file, instructions) + self.assertEqual(result.get("ttlMs"), 0) + self.assertEqual(result.get("cacheScope"), "private") + finally: + self.stop_process(process) + + def test_stdio_discover_without_meta_stays_unknown_and_sends_the_client_to_the_handshake(self) -> None: + """A bare probe is a legacy request, and discover is a modern method. + + Answering it would mean guessing that a client which stated no + protocol version speaks the newest one. The client reads the error and + handshakes instead, which is the path it already has. + """ + + process = self.start_stdio_server() + try: + probe = self.stdio_rpc_allow_error( + process, + {"jsonrpc": "2.0", "id": "bare-probe", "method": "server/discover", "params": {}}, + ) self.assertEqual(probe.get("error", {}).get("code"), -32601) + self.assertNotIn("result", probe) self.assertIsNone(process.poll(), "an unsupported probe must not end the stdio session") finally: self.stop_process(process) From bcc2d76e9a9454d7fb4accca3bea944090b7b82e Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:09:05 +0000 Subject: [PATCH 17/25] Document the dual-era contract and prepare the 0.3.0 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.2 contract described a server with sessions, a default cwd, and one protocol; none of that is true any more, so v0.3 rewrites it around the two eras: how a request picks one, what a modern _meta and its mirror headers must carry, what server/discover answers, which error maps to which HTTP status, and why the cacheable results are never shared. v0.2 is frozen where it is, still describing 0.2.x, and the schema-drift gate now reads v0.3. migration-0.3.md is the other half: every breaking change with what to do instead, the two behavior changes that are not breaking but will surprise someone, the compliance statement, and the warning that a workspace is one trust domain. The rest of the docs are closed against the same grep — the handshake is no longer the only way in, HTTP has no sessions to describe, and the contract id in the release evidence is v0.3. The 0.3.0 changelog section collects it all, with 0.2.3 recorded above 0.2.2 where it shipped, and the version files move to 0.3.0. Co-authored-by: Cursor --- CHANGELOG.md | 58 +- COMPLIANCE.md | 10 +- README.md | 18 +- README.zh-CN.md | 17 +- SECURITY.md | 11 + SPEC.md | 33 +- benchmarks/mcp_http.py | 4 +- coding_tools_mcp/__init__.py | 2 +- docs/ci-and-tests.md | 9 +- docs/competitive-analysis.md | 6 +- docs/dogfood.md | 7 +- docs/embedding.md | 26 +- docs/limitations.md | 13 + docs/mcp-client-config.md | 6 +- docs/migration-0.3.md | 189 +++++++ docs/profile.md | 6 +- docs/remote-mcp.md | 54 +- docs/runtime-contract-v0.2.md | 4 +- docs/runtime-contract-v0.3.md | 526 ++++++++++++++++++ docs/tools-and-schemas.md | 2 +- docs/troubleshooting.md | 16 +- pyproject.toml | 2 +- scripts/mcp_smoke.py | 4 + tests/compliance/runner.py | 2 +- .../runtime_semantics/semantic_vectors.json | 4 +- tests/compliance/test_docs_required.py | 3 + tests/compliance/test_schema_drift.py | 2 +- 27 files changed, 952 insertions(+), 82 deletions(-) create mode 100644 docs/migration-0.3.md create mode 100644 docs/runtime-contract-v0.3.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c881678..5fb101f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.3.0 - 2026-08-12 ### Changed @@ -112,6 +112,16 @@ supported version still gets that version back. Asking to handshake with `2026-07-28` downgrades the same way, because that protocol states its version per request instead of negotiating one. +- **Behavior change:** an HTTP request without an `MCP-Protocol-Version` header + is treated as `2025-11-25`, the newest handshake version this server speaks. + The older spec suggests assuming `2025-03-26`, which this server has never + spoken; the value only selects what is echoed and recorded, never what a + method does. +- The runtime contract is now + [docs/runtime-contract-v0.3.md](docs/runtime-contract-v0.3.md), and + [docs/migration-0.3.md](docs/migration-0.3.md) collects every breaking change + above with what to do about it. The v0.2 contract is kept, frozen, as the + 0.2.x record. ### Added @@ -121,8 +131,8 @@ `io.modelcontextprotocol/clientCapabilities` are required, `io.modelcontextprotocol/clientInfo` is optional) and may call `server/discover`, `ping`, `tools/list`, and `tools/call` immediately. A - `_meta` version this server - does not speak is answered with `-32022` and the versions it does + `_meta` version this server does not speak is answered with `-32022` and the + versions it does (`data.supported`); a missing or mistyped required `_meta` field is answered with `-32602`. Requests without that `_meta` key — including legacy requests that carry `_meta.progressToken`, and every `initialize` — keep the @@ -156,20 +166,40 @@ - CORS preflight allows `Mcp-Method` and `Mcp-Name`, and no longer allows `Mcp-Session-Id`. - Results for `2026-07-28` requests carry `resultType: "complete"` and an - `_meta.io.modelcontextprotocol/serverInfo`, and `tools/list` also carries the - conservative cache hints `ttlMs: 0` and `cacheScope: "private"` on the result - root. Responses to handshake clients are byte-for-byte what they were and - never carry these fields. + `_meta.io.modelcontextprotocol/serverInfo`; `tools/list` and + `server/discover` also carry the conservative cache hints `ttlMs: 0` and + `cacheScope: "private"` on the result root. Responses to handshake clients + are byte-for-byte what they were and never carry these fields. ### Fixed -- A second `initialize` on one persistent STDIO session now replays the - negotiated handshake result instead of failing with `-32600 Server is already - initialized`. Connectors that probe for a newer protocol, fall back to the - legacy handshake, and then re-send `initialize` on the same process could not - finish a tool scan at all. The replay reuses the existing session, so no - session state is reset and no extra telemetry session is recorded; a repeat - that asks for a different protocol version is still rejected. +- A repeated `initialize` on one persistent STDIO process is answered instead + of failing with `-32600 Server is already initialized`. Connectors that probe + for a newer protocol, fall back to the handshake, and then send `initialize` + again on the same process could not finish a tool scan at all (issue #39). + This shipped first in 0.2.3, which replayed the negotiated result; here there + is no handshake state to replay, so each `initialize` simply negotiates and + answers on its own, and a repeat naming a different supported version is + answered with that version rather than rejected. +- Two clients patching the same file no longer lose an update. Each HTTP + session used to own a runtime with its own patch lock while the files they + wrote were shared, so a second patch could validate against a baseline it had + read before the first one committed and overwrite it silently. One runtime + now owns the workspace, so its lock covers every client: the later patch is + answered with a retryable conflict. + +## 0.2.3 - 2026-08-12 + +### Fixed + +- A repeated `initialize` on one persistent STDIO session now replays the + negotiated handshake result instead of failing with `-32600 Server is + already initialized`. The initializer does not run again, so no session + state is reset and no second telemetry session is recorded. Connectors that + send `initialize` twice on the same process — the OpenAI Secure MCP Tunnel + probes with `server/discover` and then initializes twice — had their tool + scan aborted, surfacing as HTTP 424, even though the session was healthy + (issue #39). ## 0.2.2 - 2026-07-28 diff --git a/COMPLIANCE.md b/COMPLIANCE.md index 5cbca2a..8a0dacb 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -6,7 +6,7 @@ The one-command acceptance gate is: make compliance ``` -It runs protocol, golden tool, security, E2E, runtime-semantics, dogfood, compliance-report, required docs/evidence/workflow, and schema-drift checks. Report files: +It runs protocol, dual-era, golden tool, security, E2E, runtime-semantics, dogfood, compliance-report, required docs/evidence/workflow, and schema-drift checks. Report files: - [reports/compliance/latest.json](reports/compliance/latest.json) - [reports/compliance/latest.md](reports/compliance/latest.md) @@ -23,10 +23,12 @@ It adds lint, typecheck, unittest discovery, required docs checks, schema-drift ## Coverage -- MCP initialize, `tools/list`, `tools/call`, schemas, annotations, structured success/failure output, unknown tool behavior, protocol errors, trace redaction, and stdout cleanliness. +- Protocol versions: full support for MCP `2026-07-28` — every implemented method, its `params._meta` validation, the SEP-2243 mirror headers, `server/discover`, result shaping, and the HTTP status each error maps to — alongside the handshake era `2025-11-25` and `2025-06-18`. `tools` with `listChanged: false` is the only advertised capability in either era. +- MCP `initialize`, `tools/list`, `tools/call`, schemas, annotations, structured success/failure output, unknown tool behavior, protocol errors, trace redaction, and stdout cleanliness. +- Interoperability with a client we did not write: the official MCP python SDK drives the server over stdio and HTTP, and CI fails rather than skips if it is not installed. - Tool golden cases for read/list/search/patch/exec/stdin/kill/git status/git diff/image. -- Security cases for traversal, absolute paths, symlink escape, command workdir escape, direct and interpreter-mediated outside reads, direct syscall outside reads and writes where Landlock is available, destructive command policy, shell-expansion gating, obfuscated network access, risky env rejection, Linux Landlock fallback warnings, session timeout enforcement, watchdog cleanup, bounded output buffers, request-permission non-grants, and concurrent read-only calls. -- Deterministic E2E loops for JavaScript bugfix, Python function add, long-running stdin, session close behavior, workspace escape denial, and image viewing. +- Security cases for traversal, absolute paths, symlink escape, command workdir escape, direct and interpreter-mediated outside reads, direct syscall outside reads and writes where Landlock is available, destructive command policy, shell-expansion gating, obfuscated network access, risky env rejection, Linux Landlock fallback warnings, command timeout enforcement, watchdog cleanup, bounded output buffers, request-permission non-grants, and concurrent read-only calls. +- Deterministic E2E loops for JavaScript bugfix, Python function add, long-running stdin, command termination, workspace escape denial, and image viewing. - MCP-only dogfood without direct filesystem or shell bypass during task execution. - Compliance report generation semantics, including non-overclaiming partial-suite tool coverage. diff --git a/README.md b/README.md index 459085c..685e6a0 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,12 @@ same everywhere (swap `uvx` for `npx` if you prefer Node): Then ask your client: *"run the test suite and fix the first failure."* Prefer HTTP? Drop `--stdio` and the server speaks Streamable HTTP on -`http://127.0.0.1:8765/mcp` (MCP `2025-11-25`, with `2025-06-18` -compatibility). A one-line installer, per-client walkthroughs, and -troubleshooting live in [docs/quickstart.md](docs/quickstart.md) and +`http://127.0.0.1:8765/mcp`. Both protocol eras are served on either +transport: MCP `2026-07-28` in full, with `tools` as the only advertised +capability, and the handshake era `2025-11-25` with `2025-06-18` +compatibility. Neither has sessions. A one-line installer, per-client +walkthroughs, and troubleshooting live in +[docs/quickstart.md](docs/quickstart.md) and [docs/mcp-client-config.md](docs/mcp-client-config.md). ## Seven things to try @@ -139,11 +142,12 @@ rollback. | Git | `git_status` · `git_diff` · `git_log` · `git_show` · `git_blame` | | Runtime | `server_info` · `check_exec_environment` | -Root `AGENTS.md`/`CLAUDE.md` files load into the initialize context -automatically. Tool `content` is concise agent-facing text; +Root `AGENTS.md`/`CLAUDE.md` files load automatically and come back in the +`instructions` of `initialize`, or of `server/discover` for a client that +never handshakes. Tool `content` is concise agent-facing text; `structuredContent` carries the complete machine result. Schemas and result envelopes: [docs/tools-and-schemas.md](docs/tools-and-schemas.md) · -[docs/runtime-contract-v0.2.md](docs/runtime-contract-v0.2.md). +[docs/runtime-contract-v0.3.md](docs/runtime-contract-v0.3.md). ## Safety Boundary @@ -190,7 +194,7 @@ measured. More: [COMPLIANCE.md](COMPLIANCE.md) · [BENCHMARK.md](BENCHMARK.md) | --- | --- | | Getting started | [Quickstart](docs/quickstart.md) · [Client configuration](docs/mcp-client-config.md) · [Troubleshooting](docs/troubleshooting.md) | | Remote & sandboxed | [Remote MCP](docs/remote-mcp.md) · [Docker sandbox](docs/docker.md) · [Cloud sandbox worker](cloudflare/sandbox-control/README.md) | -| Tools & contract | [Tools and schemas](docs/tools-and-schemas.md) · [Runtime contract](docs/runtime-contract-v0.2.md) · [Permission modes](docs/permission-modes.md) | +| Tools & contract | [Tools and schemas](docs/tools-and-schemas.md) · [Runtime contract](docs/runtime-contract-v0.3.md) · [Migrating to 0.3](docs/migration-0.3.md) · [Permission modes](docs/permission-modes.md) | | Execution | [Exec recipes](docs/exec-command-recipes.md) · [Exec troubleshooting](docs/troubleshooting-exec.md) | | Integration | [Embedding](docs/embedding.md) · [npm launcher](npm/coding-tools-mcp/README.md) | | Security & quality | [Security policy](SECURITY.md) · [Security boundary](docs/security-boundary.md) · [CI and tests](docs/ci-and-tests.md) · [Limitations](docs/limitations.md) · [Competitive analysis](docs/competitive-analysis.md) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 5dbdb83..6d39621 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,8 +60,10 @@ npx coding-tools-mcp --stdio --workspace /path/to/repo # Node 工具链 然后对你的客户端说一句:*"跑一下测试,把第一个失败修了。"* 想用 HTTP?去掉 `--stdio`,服务器就在 -`http://127.0.0.1:8765/mcp` 上讲 Streamable HTTP(MCP `2025-11-25`, -兼容 `2025-06-18`)。一行安装脚本、各客户端的完整接入指南和排障见 +`http://127.0.0.1:8765/mcp` 上讲 Streamable HTTP。两代协议在两种 transport +上同时提供:完整支持 MCP `2026-07-28`(对外声明的 capability 只有 `tools`), +同时继续支持握手时代的 `2025-11-25` 与 `2025-06-18`;两代都没有会话。 +一行安装脚本、各客户端的完整接入指南和排障见 [docs/quickstart.md](docs/quickstart.md) 与 [docs/mcp-client-config.md](docs/mcp-client-config.md)。 @@ -128,11 +130,12 @@ coding-tools-mcp-desktop | Git | `git_status` · `git_diff` · `git_log` · `git_show` · `git_blame` | | 运行时 | `server_info` · `check_exec_environment` | -仓库根部的 `AGENTS.md`/`CLAUDE.md` 会自动载入 initialize 上下文。工具的 -`content` 是给 agent 看的精炼文本,`structuredContent` 则是完整稳定的机器 -结果。Schema 与结果封装: +仓库根部的 `AGENTS.md`/`CLAUDE.md` 会自动载入,并随 `initialize` 的 +`instructions` 下发;不握手的客户端则通过 `server/discover` 拿到同一份内容。 +工具的 `content` 是给 agent 看的精炼文本,`structuredContent` 则是完整稳定的 +机器结果。Schema 与结果封装: [docs/tools-and-schemas.md](docs/tools-and-schemas.md) · -[docs/runtime-contract-v0.2.md](docs/runtime-contract-v0.2.md) +[docs/runtime-contract-v0.3.md](docs/runtime-contract-v0.3.md) ## 安全边界 @@ -174,7 +177,7 @@ SWE-bench 榜单成绩——[docs/swe-bench.md](docs/swe-bench.md) 写明了测 | --- | --- | | 上手 | [快速开始](docs/quickstart.md) · [客户端配置](docs/mcp-client-config.md) · [排障](docs/troubleshooting.md) | | 远程与沙箱 | [Remote MCP](docs/remote-mcp.md) · [Docker 沙箱](docs/docker.md) · [云沙箱 Worker](cloudflare/sandbox-control/README.md) | -| 工具与契约 | [工具与 Schema](docs/tools-and-schemas.md) · [运行时契约](docs/runtime-contract-v0.2.md) · [权限模式](docs/permission-modes.md) | +| 工具与契约 | [工具与 Schema](docs/tools-and-schemas.md) · [运行时契约](docs/runtime-contract-v0.3.md) · [迁移到 0.3](docs/migration-0.3.md) · [权限模式](docs/permission-modes.md) | | 命令执行 | [Exec 配方](docs/exec-command-recipes.md) · [Exec 排障](docs/troubleshooting-exec.md) | | 集成 | [嵌入指南](docs/embedding.md) · [npm 启动器](npm/coding-tools-mcp/README.md) | | 安全与质量 | [安全策略](SECURITY.md) · [安全边界](docs/security-boundary.md) · [CI 与测试](docs/ci-and-tests.md) · [已知限制](docs/limitations.md) · [竞品分析](docs/competitive-analysis.md) | diff --git a/SECURITY.md b/SECURITY.md index a447692..252374e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -69,6 +69,17 @@ Operators should choose one of three permission modes: Persistent commands use opaque server-owned IDs. `write_stdin` requires a live command. `kill_command` terminates only server-managed process groups. Deadlines continue to apply even if the client stops polling, and output buffers are bounded with dropped-byte metadata. +## One Workspace Is One Trust Domain + +Since 0.3.0 there are no sessions: one server process, one runtime, one set of resources, shared by every client that authenticates to that workspace. Authentication admits a client; it does not partition anything behind it. + +- Any client can read, write stdin to, or kill any command in the workspace, whoever started it, using its `command_id`. +- Retained output is consumed globally: two clients polling one command split its output rather than each seeing all of it. +- Files and patch state are shared. Concurrent `apply_patch` calls are serialized, so an edit is never lost, but they are edits to the same tree. +- The quotas — active commands, retained output entries, output bytes — are per workspace, so one busy client can exhaust what another would have used. + +Give mutually distrusting clients separate server processes with separate workspaces and separate credentials. Per-client identity and quotas are tracked in [issue #46](https://github.com/xyTom/coding-tools-mcp/issues/46); see also [docs/migration-0.3.md](docs/migration-0.3.md). + ## HTTP Exposure HTTP is intended for local MCP clients: diff --git a/SPEC.md b/SPEC.md index a1355f6..dfa54db 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,7 +1,7 @@ # Coding Tools MCP Spec -This repository implements the `coding-tools-mcp-v0.2` runtime contract defined -in [docs/runtime-contract-v0.2.md](docs/runtime-contract-v0.2.md). +This repository implements the `coding-tools-mcp-v0.3` runtime contract defined +in [docs/runtime-contract-v0.3.md](docs/runtime-contract-v0.3.md). ## Product boundary @@ -31,16 +31,23 @@ fixed. ## Protocol -- MCP `2025-11-25` is current; `2025-06-18` is explicitly supported. +- Two eras are served at once: `2026-07-28`, which carries its version, client + capabilities, and identity in each request's `params._meta`, and the + handshake era `2025-11-25` with `2025-06-18` explicitly supported. A request + belongs to the modern era if and only if its `_meta` names that version. - Streamable HTTP uses `/mcp`; stdio uses newline-delimited JSON-RPC. -- Every HTTP `Mcp-Session-Id` owns an independent `Runtime`. -- JSON-RPC batches are rejected, cancellation follows `requestId`, and - unimplemented logging is not advertised. +- There are no sessions in either era. One `Runtime` owns the workspace and + serves every client of it; HTTP issues no `Mcp-Session-Id` and `DELETE /mcp` + returns `405`. +- JSON-RPC batches are rejected, unimplemented logging is not advertised, and + `notifications/cancelled` is accepted without terminating the command the + cancelled request started — a command is stopped with `kill_command`. - `content` is agent-readable text normally sized by each tool's per-call limits, with a documented emergency safety ceiling for pathological entries. `structuredContent` is the complete stable machine result. `_meta` is optional UI space only. -- Root project instructions enter the initialization context automatically. +- Root project instructions are loaded automatically and returned in the + `instructions` of `initialize` and of `server/discover`. ## Correctness guarantees @@ -52,7 +59,9 @@ reported explicitly rather than hidden. Commands use a 10-second default yield, real POSIX PTYs, bounded active and retained-command stores, per-command and runtime output budgets, TTL cleanup, and explicit `next_action` objects for polling or truncated output. Command -handles are `command_id` values and are distinct from HTTP `Mcp-Session-Id`. +handles are `command_id` values, owned by the workspace rather than by a +client: any authenticated client of the workspace can continue, read, or kill +a command with one, and no transport event ends it. ## Security boundary @@ -61,10 +70,16 @@ Direct tools reject absolute paths, traversal, NULs, and symlink escapes. but remains a coding runtime rather than a complete container sandbox. Remote deployment must use bearer or OAuth authentication. OAuth supports protected resource metadata, PKCE S256, exact redirect binding, and RFC 7591 dynamic client -registration. +registration. Authentication admits a client to a workspace and does not +partition it: one workspace is one trust domain, shared by every client of it. ## Compatibility +Version 0.3 adds `2026-07-28` and removes every session. The handshake era is +unchanged on the wire; the cwd tools, the HTTP session, and several +`server_info` fields are not. See +[docs/migration-0.3.md](docs/migration-0.3.md). + Version 0.2 changes model-facing result text from a JSON mirror to summaries. Clients that parsed `content[0].text` as JSON must read `structuredContent`. Image base64 now appears once, in the MCP image block. Tool profiles and the diff --git a/benchmarks/mcp_http.py b/benchmarks/mcp_http.py index 32c2f24..1678398 100644 --- a/benchmarks/mcp_http.py +++ b/benchmarks/mcp_http.py @@ -2,7 +2,9 @@ """Small MCP-over-HTTP JSON-RPC client used by deterministic benchmarks. The client intentionally depends only on the Python standard library so the -dogfood path can run before project packaging is complete. +dogfood path can run before project packaging is complete. It speaks the +handshake era, which keeps the benchmark numbers comparable with earlier runs; +the `2026-07-28` path is exercised by the compliance suite instead. """ from __future__ import annotations diff --git a/coding_tools_mcp/__init__.py b/coding_tools_mcp/__init__.py index 477c05b..e850938 100644 --- a/coding_tools_mcp/__init__.py +++ b/coding_tools_mcp/__init__.py @@ -1,3 +1,3 @@ """Coding Tools MCP server package.""" -__version__ = "0.2.2" +__version__ = "0.3.0" diff --git a/docs/ci-and-tests.md b/docs/ci-and-tests.md index dc7e542..6e632a7 100644 --- a/docs/ci-and-tests.md +++ b/docs/ci-and-tests.md @@ -87,11 +87,12 @@ make benchmark-real-workloads | `make check-dispatch-inputs` | Cloudflare Worker dispatch body compared with the sandbox workflow inputs | | `make check-npm-launcher` | npm launcher argument forwarding, runner fallback, exit behavior, and package contents | | `make check-release` | Python/module/npm versions and release changelog checked against `RELEASE_TAG`, which defaults from `pyproject.toml` | -| `make test-mcp-contract` | MCP initialize, `tools/list`, schemas, annotations, structured success/error envelopes, protocol errors | +| `make test-mcp-contract` | Both protocol eras per method: the handshake, `2026-07-28` `_meta` validation and mirror headers, `tools/list`, schemas, annotations, structured success/error envelopes, protocol errors and their HTTP statuses | +| `make test-dual-era` | What only shows up with both eras on one server: handshake-era responses carry no modern field, a modern client works without ever handshaking, concurrent clients of either era, workspace races, and the official MCP python SDK driving both transports | | `make test-tool-golden` | Golden behavior for read/list/search/patch/exec/stdin/kill/git/image paths | | `make test-security` | Traversal, symlink escape, command workdir escape, risky env, shell-expansion gating, Linux Landlock fallback behavior, direct syscall denial where Landlock is available, timeout/watchdog, buffer caps | | `make test-e2e` | End-to-end coding loops through the runtime | -| `make test-runtime-semantics` | Patch/session/image behavior vectors | +| `make test-runtime-semantics` | Patch/command/image behavior vectors | | `make test-docs-required` | Required docs, evidence artifacts, and CI workflow gate checks | | `make test-schema-drift` | Live tool schema/annotation names compared against the checked-in runtime contract/docs | | `make dogfood-mcp` | Unittest MCP-only dogfood cases | @@ -100,7 +101,7 @@ make benchmark-real-workloads | `make benchmark-smoke` | SWE-bench smoke preflight and placeholder prediction validation | | `make benchmark-real-workloads` | MCP runtime smoke over real Python, Node, Rust, Go, and monorepo checkouts plus large file/output and long command cases | -Valid runner suites include `all`, `mcp-contract`, `tool-golden`, `security`, `e2e`, `runtime-semantics`, `dogfood`, `compliance-report`, `docs-required`, and `schema-drift`. +Valid runner suites include `all`, `mcp-contract`, `dual-era`, `tool-golden`, `security`, `e2e`, `runtime-semantics`, `dogfood`, `compliance-report`, `docs-required`, and `schema-drift`. ## GitHub Actions @@ -112,7 +113,7 @@ Main workflow: The main workflow also includes a `windows-msvc-smoke` job. It verifies that Windows reports unsupported TTY requests explicitly, force-kills a background -session without relying on POSIX `SIGKILL`, initializes Visual Studio with +command without relying on POSIX `SIGKILL`, initializes Visual Studio with `vcvarsall.bat x64`, checks the narrow default `core` environment, and confirms that `--shell-env-inherit all` can compile and run a single-file `cl.exe` smoke. diff --git a/docs/competitive-analysis.md b/docs/competitive-analysis.md index c5cd2c7..2175a43 100644 --- a/docs/competitive-analysis.md +++ b/docs/competitive-analysis.md @@ -6,15 +6,15 @@ selection, approvals, compaction, planning, and UI. Therefore tool-contract quality can be compared directly; end-to-end agent parity cannot be claimed from MCP unit tests alone. -| Concern | This runtime in 0.2 | Practical comparison | +| Concern | This runtime in 0.3 | Practical comparison | | --- | --- | --- | | Tool choice | One stable catalog of 18 low-level coding tools; no profiles or dynamic process tools | A fixed catalog reduces discovery and routing variance, but a host agent can still add its own tools | | Editing | `apply_patch` is the sole direct mutation primitive; it stages all files, checks baselines, preserves mode/BOM/newlines, and rolls back partial commits | A whole-file `edit_file` can be simpler for a model, while patching sends fewer unchanged bytes and gives stronger conflict/rollback behavior | | Results | Concise bounded `content`, complete `structuredContent`, image bytes once | Avoids paying model context for duplicated JSON, diffs, and base64 | | Commands | Ten-second default foreground yield; fixed `write_stdin`, `read_output`, and `kill_command`; bounded commands and real POSIX PTY | Short tests normally finish in one call; background/interactive work has explicit next actions | -| Project context | Root `AGENTS.md`/`CLAUDE.md` content is injected at initialize; nested instruction paths are indexed | Removes a separate workspace-opening call without injecting every nested rule into every task | +| Project context | Root `AGENTS.md`/`CLAUDE.md` content is returned in the `instructions` of `initialize` and `server/discover`; nested instruction paths are indexed | Removes a separate workspace-opening call without injecting every nested rule into every task | | Isolation | Workspace path checks, permission modes, environment filtering, process groups, output limits, and Linux Landlock where available | MCP annotations remain hints; enforcement is server-side | -| Transport | Independent HTTP runtimes, negotiated protocol versions, bounded sessions, OAuth DCR + PKCE, bearer auth, and stdio | Suitable as a reusable backend; it is not an agent UI or account system | +| Transport | Stateless HTTP and stdio, serving `2026-07-28` and the handshake era from one workspace runtime, with OAuth DCR + PKCE and bearer auth | Suitable as a reusable backend; it is not an agent UI or account system | Other useful reference patterns remain outside this runtime layer: Claude Code combines permissions, hooks, and scoped agent orchestration; Aider emphasizes diff --git a/docs/dogfood.md b/docs/dogfood.md index b1439a7..62ad9b6 100644 --- a/docs/dogfood.md +++ b/docs/dogfood.md @@ -14,8 +14,8 @@ Dogfood verifies that the MCP server can act as a coding-agent backend through M The deterministic runner exercises `server_info`, repo search/read, two patch-and-test loops, `git_diff`, a real PTY stdin command, `kill_command`, and workspace escape denial. The broader compliance suite separately covers every -catalog tool, timeouts, output paging, `view_image`, binary rejection, HTTP sessions, -OAuth, and transport edge cases. +catalog tool, timeouts, output paging, `view_image`, binary rejection, both +protocol eras, OAuth, and transport edge cases. The report records completion rate, total elapsed time, tool-call and byte counts, first-attempt patch success rate, poll count, all-case pass state, and @@ -33,7 +33,8 @@ make dogfood-smoke After fixture setup and server startup, task execution must use only: -- `initialize` +- `initialize` (the runner is a handshake-era client; since 0.3.0 the server + serves the other two without it) - `tools/list` - `tools/call` diff --git a/docs/embedding.md b/docs/embedding.md index dffe6cd..c43054b 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -13,6 +13,11 @@ A production-grade embedder keeps the same skeleton and adds request timeouts, EPIPE-safe writes, reconnect loops, and a SIGTERM→SIGKILL close escalation on top. +Both templates below take the handshake path (`2025-11-25`), which is the +right default for a client written by hand: it is one exchange at startup and +nothing to repeat afterwards. If you already speak `2026-07-28`, skip the +handshake entirely — see [Speaking 2026-07-28](#speaking-2026-07-28). + ## Minimal Node.js Client ```js @@ -164,6 +169,25 @@ finally: client.close() ``` +## Speaking 2026-07-28 + +The newer protocol has no handshake. Every request carries its own version and +client capabilities in `params._meta`, so a client can call a tool as its first +message, and there is no state to re-establish after a reconnect: + +```json +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"server_info","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/clientInfo":{"name":"my-agent","version":"0.1.0"}}}} +``` + +`server/discover` — the same request with `"method":"server/discover"` and no +arguments — reports the versions this server speaks, its capabilities, and the +workspace instructions, which is what `initialize` would have told you. Results +in this era carry `resultType`, an `_meta` server identity, and cache hints; +over HTTP each request also mirrors its version and method in headers (see +[remote-mcp.md](remote-mcp.md)). Drop the `_meta` and the same request is a +handshake-era one again — the two eras are decided per request, not per +connection. + ## Backend Variants The same client shape works for every deployment; only the spawn/connect step @@ -200,4 +224,4 @@ consequences for embedders: variables are still filtered outside dangerous mode. *`HOME` is redirected to a per-runtime directory; see the -[runtime contract](runtime-contract-v0.2.md). +[runtime contract](runtime-contract-v0.3.md). diff --git a/docs/limitations.md b/docs/limitations.md index 3948697..9bc981a 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -13,5 +13,18 @@ - OAuth dynamic client registrations and pending authorization codes are held in process memory. Restarting the server requires dynamic clients to register again. +- Cancelling a request does not stop the work it started any sooner. The + response is answered as the protocol requires — on stdio the loop is serial, + so the answer is already written before a cancellation could be read, and over + HTTP the modern cancellation signal is a closed response stream this server + does not detect — but the SHOULD to stop working promptly is not met. Bound + long work with `exec_command`'s timeout and terminate it with `kill_command`. + Tracked in [issue #48](https://github.com/xyTom/coding-tools-mcp/issues/48). +- A workspace is a single trust domain shared by every client authenticated to + it. Commands, retained output, and the resource quotas that bound them are one + pool per workspace rather than per client, so one client can consume what + another was going to use, and any client can read or kill any command with its + `command_id`. Per-client identity and quotas are tracked in + [issue #46](https://github.com/xyTom/coding-tools-mcp/issues/46). - Current SWE-bench scaffold is preflight-only by default; an explicit official Docker harness attempt is blocked in this environment when Docker or the harness is unavailable. - Checked-in SWE-bench predictions are placeholders until replaced by real native baseline and MCP-candidate patches. diff --git a/docs/mcp-client-config.md b/docs/mcp-client-config.md index 2dac9af..3f1edc9 100644 --- a/docs/mcp-client-config.md +++ b/docs/mcp-client-config.md @@ -1,7 +1,9 @@ # MCP Client Configuration -Use MCP protocol version `2025-11-25`. Version `2025-06-18` remains supported -for existing clients. +Two protocol eras are served at once. A client that speaks `2026-07-28` needs +no configuration and no handshake: it discovers the server with +`server/discover` and states its version in every request. A handshake client +uses `2025-11-25`, and `2025-06-18` remains supported for existing clients. ## Codex diff --git a/docs/migration-0.3.md b/docs/migration-0.3.md new file mode 100644 index 0000000..80c44ca --- /dev/null +++ b/docs/migration-0.3.md @@ -0,0 +1,189 @@ +# Migrating to coding-tools-mcp 0.3.0 + +0.3.0 adds MCP `2026-07-28` and removes every session from the server. A +handshake-era client still connects the way it always did — the wire shape of +`2025-11-25` and `2025-06-18` is unchanged — but the tool catalog, the HTTP +transport, and a few `server_info` fields did change, and this page lists all of +it. The contract itself is +[runtime-contract-v0.3.md](runtime-contract-v0.3.md). + +If all you need is the fix for the OpenAI connector that could not finish a tool +scan ([issue #39](https://github.com/xyTom/coding-tools-mcp/issues/39)), it +shipped first as **0.2.3**, a hotfix off 0.2.2 with nothing else in it. Upgrade +to 0.2.3 to get that fix alone; upgrade to 0.3.0 for the protocol work. + +## Breaking changes + +### The two cwd tools are gone; the catalog is 18 tools + +`get_default_cwd` and `set_default_cwd` are removed. There is no session to hold +a working directory, so there is nothing to set or read: a relative `path` +always resolves against the workspace root. + +- Pass a workspace-relative `path` to the file and Git tools. +- Pass `exec_command`'s `workdir` (also workspace-relative) to run somewhere + else. It defaults to the workspace root. +- `read_file`'s `next_action` continuation now repeats the workspace-relative + path it was given. A client that fed the continuation back unchanged keeps + working; one that re-based it against a session cwd must stop doing that. + +### HTTP has no sessions + +- No response carries `Mcp-Session-Id` any more, and a client that still sends + one — because it kept the header from an older server — is served normally + rather than refused with `-32001 Unknown MCP session`. +- `DELETE /mcp` returns `405` with `Allow: POST`. There is nothing to + terminate. `DELETE` is gone from the `Allow` header, from + `Access-Control-Allow-Methods`, and from the server card's + `transport.methods`. +- The 128-session ceiling and its `503`, the idle-session expiry, and the check + that a request's `MCP-Protocol-Version` matched its session are all gone with + the sessions. + +Clients that already treated the session header as optional — the spec always +made it a MAY — need no change at all. + +### The handshake is no longer an admission gate + +`tools/list`, `tools/call`, and every other implemented method are served +whether or not `initialize` came first. `-32002 Server not initialized` is never +returned; a method this server does not implement answers `-32601` before the +handshake exactly as it does after it. `initialize` is idempotent: each one +negotiates on its own and answers with what it negotiated, so a repeat naming a +different supported version is answered with that version instead of `-32600 +Server is already initialized with a different protocol version`. + +### `notifications/cancelled` no longer stops a command + +The notification is still accepted and still answered with nothing, but it no +longer terminates the command that the cancelled request started. That mapping +was keyed by the client's own JSON-RPC id, and two clients that both use `id: 1` +— which is normal — could cancel each other's commands. + +Terminate a command with `kill_command`, which names the command by its +`command_id`. The responsiveness this costs is a known limitation, tracked in +[issue #48](https://github.com/xyTom/coding-tools-mcp/issues/48). + +### Command handles are named `command_id` + +Carried over from [#34](https://github.com/xyTom/coding-tools-mcp/pull/34) and +released here for the first time: + +| 0.2.x | 0.3.0 | +| --- | --- | +| `kill_session` | `kill_command` | +| `session_id` argument of `write_stdin` / `kill_session` | `command_id` | +| `session::stdout` / `session::stderr` output refs | `command::stdout` / `command::stderr` | + +The old names are not accepted. A command is owned by the workspace rather than +by whoever started it, so any authenticated client of that workspace can +continue, read, or kill one with its `command_id`, and no transport event ends +it. + +### `server_info` field changes + +| 0.2.x | 0.3.0 | +| --- | --- | +| `protocol_version`: the one version this session negotiated | `supported_protocol_versions`: every version this server speaks, newest first | +| `default_cwd` | removed | +| — | `output_retention`: the static per-stream budget (`buffer_bytes_per_stream`, `head_bytes_per_stream`) | + +How often that budget was actually hit is a property of the process, not an +answer to whichever client asked, so the eviction and omission counters are +reported in the telemetry `session_end` event rather than by `server_info`. See +[telemetry.md](telemetry.md). + +### Server card protocol versions + +`/.well-known/mcp.json` and `/.well-known/mcp/server-card.json` report +`supportedProtocolVersions` — a list, newest first — in place of the single +`protocolVersion`. Neither is session-scoped any more. + +```json +{"supportedProtocolVersions": ["2026-07-28", "2025-11-25", "2025-06-18"]} +``` + +## Behavior changes + +**`initialize` downgrades instead of failing.** A `protocolVersion` this server +does not speak is answered with an `InitializeResult` naming the newest version +it does (`2025-11-25`), which is what the handshake spec requires, rather than +with `-32602`. Asking to handshake with `2026-07-28` downgrades the same way: +that protocol states its version per request and is never negotiated. + +**A missing `MCP-Protocol-Version` header is read as `2025-11-25`.** The older +spec suggests assuming `2025-03-26` for a request without the header, but this +server has never spoken `2025-03-26`, and answering as if it did would name a +version in the echo that no client could then use. The header value only +selects what is echoed and recorded; it never changes what a method does. Send +the header and this does not arise. + +**Legacy results gained nothing.** The fields `2026-07-28` adds — `resultType`, +`_meta.io.modelcontextprotocol/serverInfo`, `ttlMs`, `cacheScope` — appear only +in answers to requests that asked in that era. A handshake-era client's +responses are byte-for-byte what they were. + +## What a `2026-07-28` client gets + +Nothing to migrate here — this era is new — but two notes for clients that +support both: + +- The probe works. `server/discover` reports `supportedVersions: + ["2026-07-28"]`, the `tools` capability, and the workspace instructions, so a + client that discovers never has to handshake. A probe sent *without* the + modern `_meta` is a handshake-era request for a method that era does not have + and is answered `-32601`; that is the reply that sends such a client to + `initialize`, which works. +- Over HTTP, a modern request must mirror its body in `MCP-Protocol-Version` + and `Mcp-Method`, plus `Mcp-Name` for the methods that name a subject + (`tools/call`, `resources/read`, `prompts/get`). A mismatch or a missing + mirror header is `400` with `-32020`. Handshake-era requests are asked for + none of this. + +## Compliance statement + +0.3.0 claims **full support for `2026-07-28`**, with `tools` as the only +advertised capability. Nothing about that support is partial: every method this +server implements is served in that era, with the required `_meta` validation, +mirror headers, error codes, and result shaping. + +The one gap worth naming is quality of implementation rather than compliance. A +cancelled request is answered exactly as the spec says, but the work it started +is not stopped any sooner: on stdio the loop is serial, so a response is already +written before a cancellation could be read, and over HTTP the modern +cancellation signal is a closed response stream, which this server does not +detect. Nothing client-observable is violated — the mitigation is the 30-second +foreground window of `exec_command` and terminating with `kill_command` — but +the SHOULD to stop working promptly is not met. Tracked in +[issue #48](https://github.com/xyTom/coding-tools-mcp/issues/48). + +## Operator warning: one workspace is one trust domain + +Removing sessions made explicit what was already true of commands and files: +**every client that authenticates to a workspace shares that workspace.** One +server process, one runtime, one set of resources. + +- Commands are shared. Any client can `read_output`, `write_stdin` to, or + `kill_command` any command in the workspace, whoever started it. +- Output cursors are consumed globally. Two clients polling the same + `command_id` split the output between them rather than each seeing all of it. +- Patch state is shared. Concurrent `apply_patch` calls are serialized against + one another, so an edit cannot be lost, but the loser is answered with a + conflict. +- The quotas are per workspace, not per client: active commands, retained + output entries, and output bytes come from one pool, so a busy client can + exhaust what another was going to use. + +Give mutually distrusting clients separate server processes with separate +workspaces. Per-client quotas and identity are tracked in +[issue #46](https://github.com/xyTom/coding-tools-mcp/issues/46); see also +[SECURITY.md](../SECURITY.md) and [limitations.md](limitations.md). + +## Non-breaking fixes worth knowing + +- Two clients patching the same file no longer lose an update. The patch lock + now spans every client of the workspace, so the second write is answered with + a conflict instead of silently overwriting the first. +- A repeated `initialize` on one persistent stdio process is answered rather + than refused, which is what unblocked the connector in issue #39. This + shipped first in 0.2.3. diff --git a/docs/profile.md b/docs/profile.md index ed862d7..e0301d4 100644 --- a/docs/profile.md +++ b/docs/profile.md @@ -1,8 +1,10 @@ # Coding Tools MCP Contract -The active contract is [runtime-contract-v0.2.md](runtime-contract-v0.2.md). +The active contract is [runtime-contract-v0.3.md](runtime-contract-v0.3.md). +The frozen 0.2.x one is [runtime-contract-v0.2.md](runtime-contract-v0.2.md), +and [migration-0.3.md](migration-0.3.md) is the difference between them. -Contract id: `coding-tools-mcp-v0.2`. +Contract id: `coding-tools-mcp-v0.3`. The word “contract” describes the wire/runtime version. The product exposes one fixed tool set; it has no tool-selection profiles. diff --git a/docs/remote-mcp.md b/docs/remote-mcp.md index 93de652..c3cef5c 100644 --- a/docs/remote-mcp.md +++ b/docs/remote-mcp.md @@ -110,25 +110,51 @@ configuration; do not rely on the loopback fallback for a production client. ## HTTP session behavior -An HTTP client initializes without `Mcp-Session-Id`. The response returns a -new, unguessable session ID. Every later request must send both: +There are none. Since 0.3.0 this endpoint is stateless: no response carries an +`Mcp-Session-Id`, an `Mcp-Session-Id` a client kept from an older server is +ignored rather than refused, and `DELETE /mcp` returns `405` with `Allow: POST` +because there is nothing to terminate. Every request is answered by the one +runtime that owns the workspace, so a client may reconnect, change transport, +or run beside another client without losing anything. Commands are workspace +resources with their own timeout, count, output, and retention limits: any +authenticated client of the workspace can continue one with the `command_id` +that `exec_command` returned. + +A handshake-era client needs no change for this. It still sends `initialize`, +still gets the same `InitializeResult`, and simply has no session header to +echo back. + +A `2026-07-28` client sends no handshake at all. Each request states its +version in `params._meta` and mirrors that version and its method in headers +(`Mcp-Name` as well, for `tools/call`, `resources/read`, and `prompts/get`): -```text -Mcp-Session-Id: -MCP-Protocol-Version: 2025-11-25 +```bash +curl "$BASE_URL/mcp" \ + -H "Authorization: Bearer $CODING_TOOLS_MCP_AUTH_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: server/discover" \ + --data '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' + +curl "$BASE_URL/mcp" \ + -H "Authorization: Bearer $CODING_TOOLS_MCP_AUTH_TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + -H "Mcp-Method: tools/call" \ + -H "Mcp-Name: read_file" \ + --data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"README.md"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' ``` -Each ID owns separate transport-local state such as the default cwd and request -context. Commands are workspace resources instead, so another -authenticated client connected to the same workspace can continue a command -using the `command_id` returned by `exec_command`. `DELETE /mcp` terminates only -the selected transport runtime; it does not terminate workspace commands. HTTP -sessions are bounded and expire after inactivity, while commands keep their own -existing timeout, count, output, and retention limits. +A header that contradicts the body, or a missing one, is `400` with `-32020`. +An unknown method in this era is `404` with `-32601`. Handshake-era errors stay +`200` with the JSON-RPC error, as they always did. This implementation returns `405` for `GET /mcp` because it does not provide an -SSE stream. It rejects JSON-RPC batches and accepts standard -`notifications/cancelled` messages using `params.requestId`. +SSE stream. It rejects JSON-RPC batches and accepts `notifications/cancelled` +in both eras, answering with nothing; the notification does not terminate the +command the cancelled request started, which `kill_command` does. ## Local checks diff --git a/docs/runtime-contract-v0.2.md b/docs/runtime-contract-v0.2.md index c6c6dbc..fd85d8b 100644 --- a/docs/runtime-contract-v0.2.md +++ b/docs/runtime-contract-v0.2.md @@ -1,6 +1,8 @@ # Coding Tools MCP Runtime Contract v0.2 -Status: implemented contract for `coding-tools-mcp` 0.2.x. +Status: frozen. This is the implemented contract for `coding-tools-mcp` 0.2.x +and is kept as it was; the current one is +[runtime-contract-v0.3.md](runtime-contract-v0.3.md). Protocol target: MCP `2025-11-25`, with explicit compatibility for `2025-06-18`. diff --git a/docs/runtime-contract-v0.3.md b/docs/runtime-contract-v0.3.md new file mode 100644 index 0000000..73138a5 --- /dev/null +++ b/docs/runtime-contract-v0.3.md @@ -0,0 +1,526 @@ +# Coding Tools MCP Runtime Contract v0.3 + +Status: implemented contract for `coding-tools-mcp` 0.3.x. The frozen contract +for 0.2.x is [runtime-contract-v0.2.md](runtime-contract-v0.2.md); what changed +between them, and what a client has to do about it, is +[migration-0.3.md](migration-0.3.md). + +Protocol targets: MCP `2026-07-28`, which serves every request on its own, and +the handshake era `2025-11-25` with explicit compatibility for `2025-06-18`. + +This contract describes one stable, model-neutral coding tool set. There are no +tool profiles and the server does not add or remove process tools dynamically. +`apply_patch` is the only direct file-mutation primitive; `edit_file` is not +provided. Permission modes alter command policy, not the advertised catalog. + +One switch, `--dangerously-fake-readonly-annotations`, rewrites the exposure hints +in `tools/list` for clients that refuse mutating tools by annotation. It is not a +tool profile: the catalog, the schemas, and what every tool actually does are all +unchanged, and no tool is hidden. It requires `dangerous` permission mode, requires +authentication over HTTP, and is reported by `server_info.annotation_override` and +the server card, both of which continue to publish the real annotations recorded +below. Unless that switch is set, the annotations in this document are what +`tools/list` returns. + +## Two protocol eras, one server + +Both eras are served by the one runtime that owns the workspace, and neither +leaves state behind. Which era a request belongs to is decided by the request +alone: a `params._meta` carrying `io.modelcontextprotocol/protocolVersion` is a +`2026-07-28` request, and everything else is a handshake-era one. A legacy +`_meta` such as `progressToken` does not make a request modern, and `initialize` +is always the handshake, whatever `_meta` it carries. + +The only advertised server capability is stable tools with `listChanged: false`, +in both eras. Logging, resources, prompts, sampling, and elicitation are not +advertised. + +### The handshake era + +- `initialize` negotiates a version and answers with it. A version this server + does not speak — including `2026-07-28`, which is not negotiated at all — is + answered with an `InitializeResult` naming the newest version that it does + (`2025-11-25`), as the handshake spec requires. +- `initialize` is idempotent and is not an admission gate. Each one negotiates + on its own, and every other implemented method is served whether or not one + was sent first. `notifications/initialized` is accepted and answered with + nothing. +- No session is created. HTTP responses carry no `Mcp-Session-Id`, and a header + returned by a client that spoke to an older server is ignored rather than + refused. +- The results of this era are exactly what they were: no field of the modern + era is added to them, and every error is HTTP `200` with the JSON-RPC error. + +### The `2026-07-28` era + +A request states its own protocol version, so it needs no handshake and may +call `server/discover`, `ping`, `tools/list`, and `tools/call` immediately. +`notifications/cancelled` is accepted here as well. Its `params._meta` carries: + +| `_meta` key | Required | Value | +| --- | --- | --- | +| `io.modelcontextprotocol/protocolVersion` | yes | `"2026-07-28"` | +| `io.modelcontextprotocol/clientCapabilities` | yes | object, may be empty | +| `io.modelcontextprotocol/clientInfo` | no | object naming the client | + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} +``` + +Over Streamable HTTP such a request also mirrors its body in headers, as +SEP-2243 requires, so a gateway can route it without reading the body: + +- `MCP-Protocol-Version` repeats the `_meta` protocol version. +- `Mcp-Method` repeats the JSON-RPC method, on notifications too. +- `Mcp-Name` repeats the subject of the methods that name one: `params.name` + for `tools/call` and `prompts/get`, `params.uri` for `resources/read`. A + value that cannot travel as an HTTP field is wrapped as + `=?base64??=`. No other method takes this header, + `server/discover` included. + +Handshake-era requests are asked for none of these headers. A +`MCP-Protocol-Version: 2026-07-28` header over a body that carries no modern +`_meta` is a mirror violation like any other. + +### `server/discover` + +The probe a `2026-07-28` client sends in place of a handshake. It reports the +versions this server speaks per request, its capabilities, and the workspace +instructions: + +```json +{ + "supportedVersions": ["2026-07-28"], + "capabilities": {"tools": {"listChanged": false}}, + "instructions": "...", + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "coding-tools-mcp", + "title": "Coding Tools MCP", + "version": "0.3.0" + } + } +} +``` + +`supportedVersions` lists the modern versions only. The handshake versions are +negotiated by `initialize` and are not accepted in `_meta`, so naming one here +would invite a client to retry a version that cannot work. + +A `server/discover` that carries no modern `_meta` is a handshake-era request +for a method this server does not implement in that era, and is answered with +`-32601`. That is what sends a client which probes before it handshakes to +`initialize`, which works. + +### Result encoding + +A `2026-07-28` result carries `resultType: "complete"` and an +`_meta.io.modelcontextprotocol/serverInfo` naming this server. The results +whose content a client might be tempted to keep — `tools/list` and +`server/discover` — also carry `ttlMs: 0` and `cacheScope: "private"` on the +result root. Both are shaped by the workspace and the permission mode they were +served under, and the discover instructions quote the workspace's own +instruction files, so the conservative defaults are the correct ones: never +shared, never reused. A tool result that failed still reports +`resultType: "complete"` with `isError: true`; the envelope was complete, the +tool was not. + +### Errors and HTTP statuses + +| Code | Meaning | HTTP status of a `2026-07-28` request | +| --- | --- | --- | +| `-32600` | invalid request, or an `MCP-Protocol-Version` header naming a version this server does not know (`data.supported` lists both eras) | `400` | +| `-32601` | unknown method | `404` | +| `-32602` | invalid params, including a missing or mistyped required `_meta` field | `400` | +| `-32020` | headers do not mirror the body: missing, or contradicting it | `400` | +| `-32022` | `_meta` names a protocol version this server does not speak; `data.supported` lists the modern versions only | `400` | +| `-32603` | unexpected server failure | `200` | +| `-32700` | parse error | `400` | + +Every handshake-era error stays HTTP `200` with the JSON-RPC error in the body, +which is the only thing a client of that era reads. `-32002 Server not +initialized` and `-32001 Unknown MCP session` are never returned by any era. + +### Transports + +- Streamable HTTP uses `POST /mcp`. There are no sessions, so `DELETE /mcp` + returns `405` with `Allow: POST`. Because this server does not provide an SSE + stream, `GET /mcp` and `HEAD /mcp` return `405` as well. +- A request without an `MCP-Protocol-Version` header is treated as + `2025-11-25`, this server's newest handshake version. The older spec suggests + assuming `2025-03-26`, a version this server does not speak; the value only + selects what is echoed and recorded, never what a method does. +- JSON-RPC batches are rejected. +- `notifications/cancelled` is accepted in both eras and answered with nothing, + but it does not terminate the command the cancelled request started. A + command outlives its request and is shared by every client of the workspace; + terminate one with `kill_command`. +- stdio is newline-delimited JSON-RPC. stdout contains protocol messages only; + diagnostics and logs go to stderr. +- The server card at `/.well-known/mcp.json` and + `/.well-known/mcp/server-card.json` reports `supportedProtocolVersions`, + every version this server speaks, newest first. + +## Automatic project context + +The server loads bounded root project instructions from `AGENTS.md`, +`AGENTS.MD`, `CLAUDE.md`, and `CLAUDE.MD` when present. The content is returned +in the `instructions` field of `initialize` and of `server/discover`, so an +agent of either era does not need an `open_workspace` call. Nested instruction +files are indexed by path but are not eagerly injected. Loading is UTF-8 safe +and bounded by file-count, scan-count, depth, per-file, and total-byte limits. + +## Workspace and patch guarantees + +- One server runtime owns one canonical workspace root and serves every client + of it. Concurrent clients share the command pool, the retained output, and + the patch baselines; this is a single trust domain by design. +- Direct path inputs are workspace-relative and always resolve against the + workspace root. Absolute paths, `..` traversal, NUL bytes, and symlink + escapes are rejected. +- `apply_patch` parses and validates every operation before committing, under a + lock that spans every client, so two clients patching one file cannot lose + an update: the later one is answered with a conflict rather than silently + overwriting. +- Every replacement is prepared and fsynced in the target directory, then + installed with `os.replace`. +- Existing mode bits, UTF-8 BOMs, and CRLF/LF style are preserved. Moves inherit + the source mode. +- Baseline hashes and modes are checked before commit and again immediately + before replacement. Conflicts are retryable and never silently overwrite a + newly-created target. +- A failed multi-file commit restores all backups. Portable filesystems do not + offer a true transaction across directories, so a rollback failure is + reported explicitly as `PATCH_ROLLBACK_FAILED` with recovery details. + +## Result contract + +Every valid `tools/call` response contains: + +```json +{ + "content": [{"type": "text", "text": "Short agent-readable result"}], + "structuredContent": {"ok": true}, + "isError": false +} +``` + +`content` is concise model-facing text and is never a JSON serialization of the +whole payload. Its normal size is governed by each tool's own per-call limits +(`max_bytes`, `max_output_bytes`, `max_results`, ...), without the former +16 KiB renderer preview cap. A 2,162,688-byte emergency safety ceiling protects +clients from pathological individual entries that count-based limits cannot +bound. Command results always begin with a status line (status, exit code, +signal, timeout). Stable pageable truncation names an executable continuation +call (`read_output(output_ref=..., offset=...)`, +`read_file(path=..., start_line=...)`, ...); non-pageable results explicitly +say which limit or scope to change. `structuredContent` is the complete, +stable machine-readable interface. Large diffs and command output are not +copied into `_meta`; `_meta` is optional UI extension space only. + +Tool failures keep the same envelope with `isError: true`, a readable error in +`content`, and this machine shape: + +```json +{ + "ok": false, + "error": { + "code": "PATCH_CONTEXT_AMBIGUOUS", + "message": "Patch context matched more than one location.", + "category": "validation", + "retryable": true, + "details": {"path": "src/app.py", "hunk_index": 0, "match_count": 2} + } +} +``` + +Known tool error codes include: + +```json +["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] +``` + +Error categories are `validation`, `security`, `permission`, `runtime`, +`not_found`, `conflict`, and `internal`. + +Malformed JSON-RPC uses standard protocol errors: parse `-32700`, invalid +request `-32600`, unknown method `-32601`, invalid params/tool `-32602`, and +unexpected server failure `-32603`. The two codes the modern era adds are +`-32020` and `-32022`, described above. + +## Command lifecycle + +`exec_command`, `write_stdin`, `read_output`, and `kill_command` are always in +the catalog. `exec_command` and `write_stdin` default to a 10-second yield. A +short command normally finishes in one call. A running command returns: + +```json +{ + "status": "running", + "command_id": "...", + "next_action": { + "tool": "write_stdin", + "arguments": {"command_id": "...", "chars": "", "yield_time_ms": 10000} + } +} +``` + +Call `write_stdin` with empty `chars` to poll. `read_output` is needed only when +output is truncated or a caller explicitly requested compact retained output. +Its offsets are absolute and independent for stdout and stderr. A single +truncated stream is selected by `next_action`; when both streams are truncated, +`next_actions` contains one executable `read_output` call for each stream. + +A command belongs to the workspace, not to the client or the request that +started it. Any authenticated client of the same workspace can continue, read, +or terminate one with its `command_id`, and no transport event — a closed HTTP +response, a cancelled request, a reconnect — ends it. Active processes, +completed-output commands, per-command bytes, and total runtime bytes are +bounded, all of them per workspace rather than per client. Completed commands +have a TTL. POSIX `tty=true` uses a real pseudo-terminal; Windows reports +`TTY_UNSUPPORTED` in this build instead of pretending pipes are a TTY. + +## HTTP authentication + +Non-loopback deployment requires bearer or OAuth authentication unless the +operator explicitly selects no-auth. OAuth implements Authorization Code + +PKCE S256, protected-resource metadata, authorization-server metadata, exact +redirect URI matching, one-time five-minute codes, 24-hour access tokens, and +RFC 7591 dynamic client registration at `POST /oauth/register`. Public and +confidential clients are bound to their registered authentication method. + +Authentication admits a client to the workspace; it does not partition it. +Every admitted client of one workspace shares that workspace's commands, +retained output, and patch state. + +Dynamic registrations and authorization codes are process-local; restarting +the server requires clients to register again. Configure a stable +`CODING_TOOLS_MCP_OAUTH_TOKEN_SECRET` and public server URL only when tokens must +survive tunnel churn. Forwarded headers are ignored unless +`CODING_TOOLS_MCP_TRUST_PROXY_HEADERS=1` is explicitly set. + +## Stable tool inventory + +The default catalog has 18 tools, including `view_image`. Setting +`CODING_TOOLS_MCP_ENABLE_VIEW_IMAGE=0` is the sole installation capability gate +and removes only that optional binary-content tool. It is not a tool profile. + +Each definition below lists the live input property names and annotations. The +authoritative JSON Schemas are returned by `tools/list` and checked for drift in +CI. The annotations recorded here are the truthful ones and are what `server_info` +and the server card always report, including while +`--dangerously-fake-readonly-annotations` is rewriting the hints in `tools/list`. + +### server_info + +Inputs: none. + +Annotations: `{"title":"Server info","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +Returns server version, `supported_protocol_versions`, workspace, fixed tool +count, auth state, permission mode, runtime directories, project-context +metadata, exec policy, and the static retained-output budget. It reports no +per-session value and no runtime counter: there is no session, and how often a +budget was hit is a property of the process rather than an answer to whichever +client asked. Those counters travel with telemetry. + +### check_exec_environment + +Inputs: none. + +Annotations: `{"title":"Check exec environment","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +Returns lightweight policy and Landlock status without running active probes. + +### read_file + +Inputs: `"path"`, `"start_line"`, `"end_line"`, `"max_lines"`, `"max_bytes"`, `"encoding"`. + +Annotations: `{"title":"Read file","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +Reads UTF-8 ranges as a stream, reports full file line/byte metadata, rejects +binary content, and returns continuation metadata when bounded. The +continuation repeats the workspace-relative path it was given. + +### list_dir + +Inputs: `"path"`, `"recursive"`, `"max_depth"`, `"max_entries"`, `"include_hidden"`, `"include_ignored"`, `"sort"`. + +Annotations: `{"title":"List directory","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +### list_files + +Inputs: `"path"`, `"patterns"`, `"glob"`, `"exclude_patterns"`, `"include_hidden"`, `"include_ignored"`, `"max_results"`, `"sort"`. + +Annotations: `{"title":"List files","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +Traversal is iterative and git-ignore checks are batched. + +### search_text + +Inputs: `"query"`, `"path"`, `"regex"`, `"case_sensitive"`, `"include_globs"`, `"glob"`, `"exclude_globs"`, `"context_lines"`, `"max_results"`, `"max_preview_bytes"`. + +Annotations: `{"title":"Search text","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +Ripgrep output is consumed incrementally and the process stops once the result +cap is known to be exceeded. `context_lines=0` does not reread matching files. + +### apply_patch + +Inputs: `"patch"`, `"dry_run"`. + +Annotations: `{"title":"Apply patch","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}`. + +Supports `*** Add File`, `*** Update File`, `*** Delete File`, and +`*** Move to` inside a `*** Begin Patch` / `*** End Patch` envelope. + +```text +*** Begin Patch +*** Update File: app.py +@@ +-old ++new +*** End Patch +``` + +### exec_command + +Inputs: `"cmd"`, `"workdir"`, `"cwd"`, `"timeout_ms"`, `"yield_time_ms"`, `"max_output_bytes"`, `"verbosity"`, `"preview_bytes"`, `"stdin"`, `"tty"`, `"env"`. + +Annotations: `{"title":"Execute command","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":true}`. + +Statuses are `exited`, `running`, `timeout`, `terminated`, or `failed`. +Launch/policy failures use the error envelope with `status: "failed"`; signal +exits use `terminated`. Ordinary non-zero exit codes still use `exited`. +`"workdir"` is workspace-relative and defaults to the workspace root. + +Example: `{"cmd":"pytest -q","workdir":".","yield_time_ms":30000}`. + +### write_stdin + +Inputs: `"command_id"`, `"chars"`, `"yield_time_ms"`, `"max_output_bytes"`, `"verbosity"`, `"preview_bytes"`. + +Annotations: `{"title":"Write stdin","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}`. + +Poll or interact with a command. Pass empty `chars` to wait for output. + +Poll example: `{"command_id":"abc","chars":"","yield_time_ms":10000}`. +Input example: `{"command_id":"abc","chars":"yes\n"}`. + +### kill_command + +Inputs: `"command_id"`, `"signal"`, `"wait_ms"`, `"kill_wait_ms"`, `"max_output_bytes"`, `"verbosity"`, `"preview_bytes"`. + +Annotations: `{"title":"Kill command","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}`. + +Statuses are `["terminated", "killed", "exited", "terminating", "not_found"]`. + +If the process is still alive `"wait_ms"` after a non-KILL signal, the runtime +escalates to a hard kill and waits up to `"kill_wait_ms"` for the exit. This is +how a client stops a command: `notifications/cancelled` does not. + +Example: `{"command_id":"abc","signal":"KILL"}`. + +### read_output + +Inputs: `"output_ref"`, `"stream"`, `"offset"`, `"limit"`. + +Annotations: `{"title":"Read output","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +Retention is head+tail per stream: the earliest bytes (head) and the most +recent bytes (rolling tail) are kept; the range between them may be evicted +once the per-stream buffer overflows. Responses report `head_retained_bytes`, +`evicted_gap_bytes`, and `omitted_bytes`; reads inside the evicted range clamp +forward to the tail. Offsets remain absolute and stable. + +Example: `{"output_ref":"command:abc:stdout","offset":0,"limit":4096}`. + +### git_status + +Inputs: `"path"`, `"include_untracked"`, `"max_entries"`. + +Annotations: `{"title":"Git status","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +### git_diff + +Inputs: `"path"`, `"paths"`, `"staged"`, `"unstaged"`, `"context_lines"`, `"max_bytes"`. + +Annotations: `{"title":"Git diff","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +### git_log + +Inputs: `"path"`, `"ref"`, `"max_count"`, `"skip"`. + +Annotations: `{"title":"Git log","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +### git_show + +Inputs: `"rev"`, `"path"`, `"paths"`, `"include_diff"`, `"context_lines"`, `"max_bytes"`. + +Annotations: `{"title":"Git show","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +### git_blame + +Inputs: `"path"`, `"rev"`, `"start_line"`, `"end_line"`, `"max_lines"`. + +Annotations: `{"title":"Git blame","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +### request_permissions + +Inputs: `"tool_name"`, `"permission"`, `"reason"`, `"arguments"`, `"scope"`, `"ttl_seconds"`. + +Annotations: `{"title":"Request permissions","readOnlyHint":true,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}`. + +The current server does not advertise MCP elicitation. This tool therefore +returns `ELICITATION_UNSUPPORTED`, except that dangerous mode reports the +operator's explicit auto-grant policy. It never silently escalates safe mode. + +### view_image + +Inputs: `"path"`, `"max_bytes"`, `"max_width"`, `"max_height"`, `"auto_resize"`. + +Annotations: `{"title":"View image","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +The base64 data appears exactly once, in one MCP image content block. Stable +`structuredContent` contains metadata only; it has no duplicate base64 or data +URL. Pillow is optional and used only for requested auto-resize. + +## Forbidden product-layer tools + +The runtime does not expose external-agent login/accounts, agent memory, cloud +tasks, web search/fetch, image generation, model routing, plugin installation, +subagent orchestration, or high-level prompt wrappers. + +## Known limitation: cancellation responsiveness + +A cancelled request is answered as this contract says it is, in both eras, but +the work it started is not stopped any sooner. On stdio the loop is serial, so +the response is already written by the time a cancellation could be read; over +HTTP the modern cancellation signal is a closed response stream, and this +server does not detect a disconnect. No client-observable rule is broken — +nothing is sent for a cancelled request that would not have been sent anyway — +but the SHOULD to stop working promptly is not met. The mitigations are the +30-second foreground window of `exec_command` and terminating a command with +`kill_command`. Tracked in issue +[#48](https://github.com/xyTom/coding-tools-mcp/issues/48). + +## Compatibility note for 0.3 + +0.2 clients keep working: the handshake era is unchanged on the wire. What +changed for them is the tool catalog and the transport, not the envelope — +`get_default_cwd` and `set_default_cwd` are gone, relative paths resolve +against the workspace root, and HTTP no longer has sessions. Every removal, and +what to do instead, is in [migration-0.3.md](migration-0.3.md). diff --git a/docs/tools-and-schemas.md b/docs/tools-and-schemas.md index d475561..76ae7ad 100644 --- a/docs/tools-and-schemas.md +++ b/docs/tools-and-schemas.md @@ -1,6 +1,6 @@ # Tools And Schemas -The normative behavior is [runtime-contract-v0.2.md](runtime-contract-v0.2.md). +The normative behavior is [runtime-contract-v0.3.md](runtime-contract-v0.3.md). Live JSON Schemas come from `tools/list`; CI compares their names, input properties, annotations, and error codes with the contract. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fc41b38..f0bc4c9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,9 +2,19 @@ ## Protocol Version Errors -HTTP clients should send the version negotiated at initialization, normally -`MCP-Protocol-Version: 2025-11-25`. Compatibility clients may negotiate -`2025-06-18`; unsupported versions return a JSON-RPC error. +Send `MCP-Protocol-Version` on every HTTP request. A `2026-07-28` client sends +`MCP-Protocol-Version: 2026-07-28`, repeating the version in its +`params._meta`; a header that disagrees with the body, or is missing from such +a request, returns `400` with `-32020`. A handshake client sends the version it +negotiated at initialization, normally `MCP-Protocol-Version: 2025-11-25`, or +`2025-06-18` for compatibility. A header naming a version this server does not +know returns `400` with `-32600` and lists the ones it does; a request with no +header at all is read as `2025-11-25`. + +Asking to handshake with a version this server does not speak is no longer an +error: `initialize` answers with the newest version it does speak. If a client +seems to be on an older protocol than expected, read the `protocolVersion` in +the `InitializeResult` rather than assuming the one that was requested. ## SANDBOX_UNAVAILABLE diff --git a/pyproject.toml b/pyproject.toml index f5e949b..a73cbbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "coding-tools-mcp" -version = "0.2.2" +version = "0.3.0" description = "Workspace-confined coding tools exposed as an MCP server." requires-python = ">=3.11" dependencies = [ diff --git a/scripts/mcp_smoke.py b/scripts/mcp_smoke.py index 576da14..822958a 100644 --- a/scripts/mcp_smoke.py +++ b/scripts/mcp_smoke.py @@ -7,6 +7,10 @@ Verifies initialize + tools/list + server_info, then runs each CMD through exec_command expecting a clean exit. Bearer auth is taken from the CODING_TOOLS_MCP_AUTH_TOKEN environment variable (read by the shared client). + +This takes the handshake path (2025-11-25) because the shared client does: +it is a deployment check, not a protocol one. The 2026-07-28 path is covered +by tests/compliance/test_dual_era.py and test_mcp_contract.py. """ from __future__ import annotations diff --git a/tests/compliance/runner.py b/tests/compliance/runner.py index b646a92..ad8bac1 100644 --- a/tests/compliance/runner.py +++ b/tests/compliance/runner.py @@ -18,7 +18,7 @@ REPORT_DIR = ROOT / "reports" / "compliance" JSON_REPORT = REPORT_DIR / "latest.json" MD_REPORT = REPORT_DIR / "latest.md" -CONTRACT = "coding-tools-mcp-v0.2" +CONTRACT = "coding-tools-mcp-v0.3" SUITES = { "mcp-contract": ["tests.compliance.test_mcp_contract"], diff --git a/tests/compliance/runtime_semantics/semantic_vectors.json b/tests/compliance/runtime_semantics/semantic_vectors.json index b1f757b..e3209cc 100644 --- a/tests/compliance/runtime_semantics/semantic_vectors.json +++ b/tests/compliance/runtime_semantics/semantic_vectors.json @@ -1,6 +1,6 @@ { - "contract": "coding-tools-mcp-v0.2", - "source": "Semantic vectors for patch envelope and shell/session behavior.", + "contract": "coding-tools-mcp-v0.3", + "source": "Semantic vectors for patch envelope and command behavior.", "apply_patch": [ { "name": "add_file", diff --git a/tests/compliance/test_docs_required.py b/tests/compliance/test_docs_required.py index db742f4..60b3e6c 100644 --- a/tests/compliance/test_docs_required.py +++ b/tests/compliance/test_docs_required.py @@ -29,6 +29,8 @@ def test_required_operator_docs_exist(self) -> None: "docs/telemetry.md", "docs/troubleshooting.md", "docs/competitive-analysis.md", + "docs/runtime-contract-v0.3.md", + "docs/migration-0.3.md", "docs/runtime-contract-v0.2.md", "Dockerfile", ".dockerignore", @@ -72,6 +74,7 @@ def test_docs_contain_required_operational_topics(self) -> None: "docs/security-boundary.md": ["Landlock", "external container or VM"], "docs/docker.md": ["permission-mode trusted", "permission_mode=dangerous", "mvn -version"], "docs/competitive-analysis.md": ["Claude Code", "Aider", "OpenHands", "Cline"], + "docs/migration-0.3.md": ["Breaking changes", "server/discover", "one trust domain"], } for rel_path, needles in expectations.items(): text = (ROOT / rel_path).read_text(encoding="utf-8") diff --git a/tests/compliance/test_schema_drift.py b/tests/compliance/test_schema_drift.py index 836cb88..aefe72f 100644 --- a/tests/compliance/test_schema_drift.py +++ b/tests/compliance/test_schema_drift.py @@ -13,7 +13,7 @@ class SchemaDriftTests(unittest.TestCase): - CONTRACT_PATH = ROOT / "docs/runtime-contract-v0.2.md" + CONTRACT_PATH = ROOT / "docs/runtime-contract-v0.3.md" def test_input_schemas_cover_exactly_the_registered_tools(self) -> None: self.assertEqual(set(input_schemas()), set(TOOL_REGISTRY)) From a03882dab098d2f0ed49afa4430054712d415e2b Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:37:41 +0000 Subject: [PATCH 18/25] Harden dual-era validation order, notification silence, and telemetry hygiene Check a modern request's _meta before the mirror headers that repeat it, so an unsupported version or a mistyped field answers with -32022/-32602 on HTTP as it already did on stdio, instead of the -32020 the mirror would reach first. Reject a mirror header sent more than once, and cap the base64 sentinel payload. Answer a notification with nothing when its handling fails, on both transports, rather than a null-id error. Stop deep-copying the client _meta: keep only the two sanitized clientInfo strings anything reads, and drop the unused capabilities copy that a nested payload could turn into a RecursionError. Sanitize the method name in the first-appearance stderr line, tighten the clientInfo character set so an address or path cannot travel verbatim, and build no telemetry event while telemetry is off. Docs and CHANGELOG follow. Co-authored-by: Cursor --- CHANGELOG.md | 5 +- coding_tools_mcp/protocol.py | 96 ++++++++---- coding_tools_mcp/server.py | 71 +++++++-- coding_tools_mcp/telemetry.py | 20 ++- coding_tools_mcp/transport_stdio.py | 23 ++- docs/migration-0.3.md | 18 ++- docs/runtime-contract-v0.3.md | 50 +++++-- docs/telemetry.md | 8 +- tests/compliance/test_mcp_contract.py | 204 ++++++++++++++++++++++++-- tests/test_telemetry.py | 98 ++++++++++--- 10 files changed, 497 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fb101f..2093c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,8 +115,9 @@ - **Behavior change:** an HTTP request without an `MCP-Protocol-Version` header is treated as `2025-11-25`, the newest handshake version this server speaks. The older spec suggests assuming `2025-03-26`, which this server has never - spoken; the value only selects what is echoed and recorded, never what a - method does. + spoken. The header value travels with the request as context and is available + to the runtime; nothing echoes it, records it, or acts on it, and no method + behaves differently for it. - The runtime contract is now [docs/runtime-contract-v0.3.md](docs/runtime-contract-v0.3.md), and [docs/migration-0.3.md](docs/migration-0.3.md) collects every breaking change diff --git a/coding_tools_mcp/protocol.py b/coding_tools_mcp/protocol.py index f6b6f6a..b5e3905 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -1,7 +1,6 @@ from __future__ import annotations import base64 -import copy from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -40,6 +39,10 @@ } BASE64_SENTINEL_PREFIX = "=?base64?" BASE64_SENTINEL_SUFFIX = "?=" +# A mirrored subject is a tool name, a URI, or a prompt name. Nothing this +# server answers needs more than a few hundred characters of it, and the +# decode happens before the body is compared, so the payload is capped. +BASE64_SENTINEL_MAX_PAYLOAD = 8192 # The method a dual-era client probes with before it decides to handshake. DISCOVER_METHOD = "server/discover" @@ -55,6 +58,11 @@ MODERN_CACHEABLE_METHODS = frozenset({DISCOVER_METHOD, "tools/list"}) MODERN_RESULT_TYPE = "complete" +# The only two ``clientInfo`` fields anything reads, and the length a label +# built from one is worth carrying. +CLIENT_INFO_KEYS = ("name", "version") +CLIENT_INFO_VALUE_LIMIT = 200 + @dataclass(frozen=True) class RequestContext: @@ -62,15 +70,14 @@ class RequestContext: One runtime serves concurrent clients, so a request carries its own context instead of parking it on runtime state. ``frozen`` freezes only - the top level: ``client_info`` and ``client_capabilities`` hold deep copies - of the validated ``_meta`` objects so a later mutation of the request body - cannot reach into a context that has already been handed on. + the top level, so ``client_info`` is not the object the request carried: + it is a small dict rebuilt from the two string fields anything reads, + which is both bounded and immune to a later mutation of the request body. """ era: str = LEGACY_ERA protocol_version: str = LATEST_LEGACY_PROTOCOL_VERSION client_info: Mapping[str, Any] | None = None - client_capabilities: Mapping[str, Any] | None = None def jsonrpc_error( @@ -158,6 +165,12 @@ def decode_mirror_header(value: str) -> str: if not (value.startswith(BASE64_SENTINEL_PREFIX) and value.endswith(BASE64_SENTINEL_SUFFIX)): return value payload = value[len(BASE64_SENTINEL_PREFIX) : -len(BASE64_SENTINEL_SUFFIX)] + if len(payload) > BASE64_SENTINEL_MAX_PAYLOAD: + raise JsonRpcError( + HEADER_MISMATCH, + f"Mirror header carries a base64 sentinel longer than {BASE64_SENTINEL_MAX_PAYLOAD} characters", + {"reason": "oversized", "max_length": BASE64_SENTINEL_MAX_PAYLOAD}, + ) try: return base64.b64decode(payload, validate=True).decode("utf-8") except ValueError as exc: # binascii.Error and UnicodeDecodeError both subclass it @@ -250,11 +263,13 @@ def request_era(method: str, params: Mapping[str, Any]) -> str: return LEGACY_ERA -def modern_request_context(params: Mapping[str, Any]) -> RequestContext: - """Validate a modern request's ``_meta`` and turn it into a context. +def validate_modern_meta(params: Mapping[str, Any]) -> str: + """Check every ``_meta`` field a modern request owes, and return its version. Only called once :func:`request_era` has found the protocol version key, so - ``_meta`` is known to be an object that carries it. + ``_meta`` is known to be an object that carries it. A transport may run + this before its own checks so that a request which is wrong in both ways + is answered with the protocol's verdict rather than the transport's. """ meta = params["_meta"] @@ -271,28 +286,48 @@ def modern_request_context(params: Mapping[str, Any]) -> RequestContext: f"Unsupported MCP protocol version in _meta: {version}", {"supported": list(MODERN_PROTOCOL_VERSIONS), "received": version}, ) - capabilities = meta.get(META_CLIENT_CAPABILITIES) - if not isinstance(capabilities, dict): + if not isinstance(meta.get(META_CLIENT_CAPABILITIES), dict): raise JsonRpcError( -32602, f"{META_CLIENT_CAPABILITIES} is required and must be an object", {"reason": "client_capabilities"}, ) - client_info: Mapping[str, Any] | None = None - if META_CLIENT_INFO in meta: - declared = meta[META_CLIENT_INFO] - if not isinstance(declared, dict): - raise JsonRpcError( - -32602, - f"{META_CLIENT_INFO} must be an object when present", - {"reason": "client_info"}, - ) - client_info = copy.deepcopy(declared) + if META_CLIENT_INFO in meta and not isinstance(meta[META_CLIENT_INFO], dict): + raise JsonRpcError( + -32602, + f"{META_CLIENT_INFO} must be an object when present", + {"reason": "client_info"}, + ) + return version + + +def bounded_client_info(declared: Mapping[str, Any]) -> dict[str, str]: + """Rebuild a client's self-description as a small, flat dict. + + A client controls both the shape and the size of what it declares, so + nothing it sent is carried onwards: only ``name`` and ``version`` are + read, only when they are strings, and each is truncated. Copying the + object instead would let an arbitrarily nested one travel with every + request that carried it. + """ + + bounded: dict[str, str] = {} + for key in CLIENT_INFO_KEYS: + value = declared.get(key) + if isinstance(value, str): + bounded[key] = value[:CLIENT_INFO_VALUE_LIMIT] + return bounded + + +def modern_request_context(params: Mapping[str, Any]) -> RequestContext: + """Validate a modern request's ``_meta`` and turn it into a context.""" + + version = validate_modern_meta(params) + declared = params["_meta"].get(META_CLIENT_INFO) return RequestContext( era=MODERN_ERA, protocol_version=version, - client_info=client_info, - client_capabilities=copy.deepcopy(capabilities), + client_info=bounded_client_info(declared) if isinstance(declared, dict) else None, ) @@ -343,13 +378,21 @@ def dispatch_rpc( so one runtime answers every client of the workspace. Transports add only their transport-specific framing (stream handling, status codes) around this, and may report the legacy version their framing negotiated through - ``transport_protocol_version``; it is echoed and recorded, never acted on. - Returns None for notifications and requests without an id. + ``transport_protocol_version``; it reaches the runtime in the request + context and is not acted on, echoed, or recorded today. + + Returns None for notifications and requests without an id. A notification + that fails is answered with nothing at all, as JSON-RPC requires: only a + message whose envelope is too malformed to tell a notification from a + request is answered with an error carrying a null id. """ - request_id = request.get("id") try: validate_rpc_envelope(request) + except JsonRpcError as exc: + return jsonrpc_error(response_id(request), exc.code, exc.message, exc.data) + is_notification = "id" not in request + try: method = request["method"] params = rpc_params(request) era = request_era(method, params) @@ -365,6 +408,7 @@ def dispatch_rpc( protocol_version=transport_protocol_version or LATEST_LEGACY_PROTOCOL_VERSION, ) result = _dispatch_legacy(runtime, request, method, params, context) + request_id = request.get("id") if result is None or request_id is None: return None return { @@ -373,6 +417,8 @@ def dispatch_rpc( "result": shape_result(context, method, result, runtime.server_identity()), } except JsonRpcError as exc: + if is_notification: + return None return jsonrpc_error(response_id(request), exc.code, exc.message, exc.data) diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 85693e3..516b202 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -82,6 +82,7 @@ request_era, response_id, validate_mirror_headers, + validate_modern_meta, validate_rpc_envelope, ) from .project_context import ProjectContext, load_project_context @@ -4798,6 +4799,10 @@ def server_card_payload(runtime: Runtime, *, oauth_base_url: str | None = None) return payload +# The headers a modern request mirrors its body in, each of which may appear +# exactly once. +MIRROR_HEADERS = ("MCP-Protocol-Version", "Mcp-Method", "Mcp-Name") + # A modern client reads the HTTP status as well as the JSON-RPC error, so the # protocol errors that name a fault in the request are reported as such. Every # other code — including -32603, which says the request was fine and we were @@ -4951,17 +4956,7 @@ def do_POST(self) -> None: if self.headers.get_content_type().lower() != "application/json": self.send_rpc_error(-32600, "Content-Type must be application/json", status=415) return - # Which era a request belongs to is decided by its body, so a version - # header naming something from neither era is refused before the body - # is read: there is nothing to decide it against. protocol_version = self.headers.get("MCP-Protocol-Version") - if protocol_version and not protocol_version_is_known(protocol_version): - self.send_rpc_error( - -32600, - "Unsupported MCP protocol version", - data={"supported": list(KNOWN_PROTOCOL_VERSIONS), "received": protocol_version}, - ) - return raw_length = self.headers.get("Content-Length") if raw_length is None: self.send_rpc_error(-32600, "Content-Length is required", status=411) @@ -4986,7 +4981,9 @@ def do_POST(self) -> None: body = self.rfile.read(length) try: request = json.loads(body.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError): + # RecursionError included: a deeply nested document is a document + # this server cannot parse, not a reason to unwind the handler. self.send_rpc_error(-32700, "Parse error") return if isinstance(request, list): @@ -5010,6 +5007,44 @@ def do_POST(self) -> None: raw_params = request.get("params") params = raw_params if isinstance(raw_params, dict) else {} era = request_era(method, params) + if era == MODERN_ERA: + duplicate = self.duplicated_mirror_header() + if duplicate is not None: + self.send_rpc_error( + HEADER_MISMATCH, + f"{duplicate} must appear exactly once", + request_id=response_id(request), + data={"header": duplicate, "reason": "duplicate"}, + ) + return + # The body's own contract comes first: a version this server does + # not speak, or a mistyped ``_meta`` field, is the same fault here + # as it is over stdio, and mirror headers that faithfully repeat a + # wrong body must not answer for it instead. A notification is + # exempt — nothing may be sent back for one — and is left to the + # dispatcher, which stays silent. + if "id" in request: + try: + validate_modern_meta(params) + except JsonRpcError as exc: + self.send_rpc_error( + exc.code, + exc.message, + status=MODERN_ERROR_STATUSES.get(exc.code, 200), + request_id=response_id(request), + data=exc.data, + ) + return + elif protocol_version and not protocol_version_is_known(protocol_version): + # A handshake-era request naming a version from neither era: the + # body cannot decide it, so the transport refuses it and offers + # everything this server speaks. + self.send_rpc_error( + -32600, + "Unsupported MCP protocol version", + data={"supported": list(KNOWN_PROTOCOL_VERSIONS), "received": protocol_version}, + ) + return try: validate_mirror_headers( era, @@ -5030,6 +5065,20 @@ def do_POST(self) -> None: return self.send_json(response, status=rpc_response_status(era, response)) + def duplicated_mirror_header(self) -> str | None: + """Name the first mirror header that was sent more than once, if any. + + A gateway routes on these headers alone, and which of two values it + reads is its own business, so a request that states its version, + method, or subject twice has no single mirror to check the body + against and is refused rather than resolved. + """ + + for header in MIRROR_HEADERS: + if len(self.headers.get_all(header) or ()) > 1: + return header + return None + def handle_rpc( self, request: dict[str, Any], diff --git a/coding_tools_mcp/telemetry.py b/coding_tools_mcp/telemetry.py index 5efb525..7711baa 100644 --- a/coding_tools_mcp/telemetry.py +++ b/coding_tools_mcp/telemetry.py @@ -61,7 +61,12 @@ # clientInfo is whatever the client says it is, so it is narrowed to a # printable ASCII subset before it can become an event property: anything else # is either an injection into the log line or unbounded cardinality. -_CLIENT_LABEL_CHARS = frozenset(string.ascii_letters + string.digits + " .,_-+/@:()") +_CLIENT_LABEL_CHARS = frozenset(string.ascii_letters + string.digits + " ._-") +# A method name is ours to expect rather than the client's to invent, but it +# still arrives from the wire, so the log line is built from the characters a +# method name is made of and nothing else. +_METHOD_LABEL_CHARS = frozenset(string.ascii_letters + string.digits + "/._-") +_UNKNOWN_LABEL = "unknown" _OFF_VALUES = {"0", "off", "false", "no", "disable", "disabled"} _DURATION_BUCKETS = ((100, "dur_lt_100ms"), (1_000, "dur_lt_1s"), (10_000, "dur_lt_10s")) _DURATION_OVERFLOW = "dur_gte_10s" @@ -109,6 +114,13 @@ def _client_label(value: Any) -> str | None: return text[:_CLIENT_LABEL_LIMIT] if text else None +def _method_label(value: Any) -> str: + """Normalize a wire method name into something safe to log on one line.""" + + text = "".join(character for character in str(value) if character in _METHOD_LABEL_CHARS).strip() + return text[:_LABEL_LIMIT] if text else _UNKNOWN_LABEL + + def _client_identity(client_info: Any) -> tuple[str | None, str | None]: """Read the sanitized ``name`` and ``version`` a client reported, if any. @@ -318,10 +330,12 @@ def record_request(self, era: str, method: str) -> None: self._discover_probes += 1 activated = self._activate_locked() if method != "ping" else False if era == MODERN_ERA: - note_first_appearance("modern-request", f"modern client request ({method})") + note_first_appearance("modern-request", f"modern client request ({_method_label(method)})") if method == DISCOVER_METHOD: note_first_appearance("discover-probe", f"{DISCOVER_METHOD} probe") - if activated: + # ``_event`` reads (and, once per install, writes) the install id, so + # it must not be built at all while telemetry is off. + if activated and telemetry_mode() != "off": self._emit([self._event("session_start", {})], wake=True) def record_session_start(self, client_info: dict[str, Any] | None, protocol_version: str) -> None: diff --git a/coding_tools_mcp/transport_stdio.py b/coding_tools_mcp/transport_stdio.py index 1a64e43..d6e1a51 100644 --- a/coding_tools_mcp/transport_stdio.py +++ b/coding_tools_mcp/transport_stdio.py @@ -4,7 +4,13 @@ import sys from typing import Any, Protocol, TextIO -from .protocol import RequestContext, dispatch_rpc, invalid_request_response, jsonrpc_error +from .protocol import ( + RequestContext, + dispatch_rpc, + invalid_request_response, + jsonrpc_error, + response_id, +) from .telemetry import SessionTelemetry @@ -50,7 +56,10 @@ def serve_stdio( continue try: request = json.loads(line) - except json.JSONDecodeError: + except (json.JSONDecodeError, RecursionError): + # RecursionError included: a deeply nested document is a + # document this server cannot parse, not a reason to end the + # session. response = jsonrpc_error(None, -32700, "Parse error") else: try: @@ -60,7 +69,15 @@ def serve_stdio( else invalid_request_response() ) except Exception as exc: # noqa: BLE001 - keep the stdio server alive - response = jsonrpc_error(None, -32603, str(exc)) + if isinstance(request, dict) and "id" not in request: + # A notification is answered with nothing, however + # badly its handling went. + continue + response = jsonrpc_error( + response_id(request) if isinstance(request, dict) else None, + -32603, + str(exc), + ) if response is not None: sink.write( json.dumps(response, separators=(",", ":")) + "\n" diff --git a/docs/migration-0.3.md b/docs/migration-0.3.md index 80c44ca..e2a995e 100644 --- a/docs/migration-0.3.md +++ b/docs/migration-0.3.md @@ -114,9 +114,9 @@ that protocol states its version per request and is never negotiated. **A missing `MCP-Protocol-Version` header is read as `2025-11-25`.** The older spec suggests assuming `2025-03-26` for a request without the header, but this server has never spoken `2025-03-26`, and answering as if it did would name a -version in the echo that no client could then use. The header value only -selects what is echoed and recorded; it never changes what a method does. Send -the header and this does not arise. +version no client could then use. The header value travels with the request as +context and nothing echoes it, records it, or acts on it; no method behaves +differently for it. Send the header and this does not arise. **Legacy results gained nothing.** The fields `2026-07-28` adds — `resultType`, `_meta.io.modelcontextprotocol/serverInfo`, `ttlMs`, `cacheScope` — appear only @@ -136,9 +136,15 @@ support both: `initialize`, which works. - Over HTTP, a modern request must mirror its body in `MCP-Protocol-Version` and `Mcp-Method`, plus `Mcp-Name` for the methods that name a subject - (`tools/call`, `resources/read`, `prompts/get`). A mismatch or a missing - mirror header is `400` with `-32020`. Handshake-era requests are asked for - none of this. + (`tools/call`, `resources/read`, `prompts/get`). A mismatch, a missing + mirror header, or one sent twice is `400` with `-32020`. Handshake-era + requests are asked for none of this. +- Falling back to the handshake means dropping the header as well. Do **not** + send `MCP-Protocol-Version: 2026-07-28` on an `initialize`, or on any other + body that carries no modern `_meta`: the header states which era the request + is in, so one that disagrees with the body is a mirror violation and is + refused with `-32020` before the handshake is read. Send the handshake + version, or no header at all. ## Compliance statement diff --git a/docs/runtime-contract-v0.3.md b/docs/runtime-contract-v0.3.md index 73138a5..5047525 100644 --- a/docs/runtime-contract-v0.3.md +++ b/docs/runtime-contract-v0.3.md @@ -85,8 +85,13 @@ SEP-2243 requires, so a gateway can route it without reading the body: - `Mcp-Name` repeats the subject of the methods that name one: `params.name` for `tools/call` and `prompts/get`, `params.uri` for `resources/read`. A value that cannot travel as an HTTP field is wrapped as - `=?base64??=`. No other method takes this header, - `server/discover` included. + `=?base64??=`, whose payload may not exceed 8192 + characters. No other method takes this header, `server/discover` included. + +Each of the three headers may appear exactly once. A gateway routes on them +alone, and which of two values it would read is its own business, so a request +that states its version, method, or subject twice is refused with `-32020` +rather than resolved. Handshake-era requests are asked for none of these headers. A `MCP-Protocol-Version: 2026-07-28` header over a body that carries no modern @@ -142,17 +147,42 @@ tool was not. | Code | Meaning | HTTP status of a `2026-07-28` request | | --- | --- | --- | -| `-32600` | invalid request, or an `MCP-Protocol-Version` header naming a version this server does not know (`data.supported` lists both eras) | `400` | +| `-32600` | invalid request envelope | `200`, in either era | | `-32601` | unknown method | `404` | | `-32602` | invalid params, including a missing or mistyped required `_meta` field | `400` | -| `-32020` | headers do not mirror the body: missing, or contradicting it | `400` | +| `-32020` | headers do not mirror the body: missing, duplicated, or contradicting it | `400` | | `-32022` | `_meta` names a protocol version this server does not speak; `data.supported` lists the modern versions only | `400` | | `-32603` | unexpected server failure | `200` | | `-32700` | parse error | `400` | -Every handshake-era error stays HTTP `200` with the JSON-RPC error in the body, -which is the only thing a client of that era reads. `-32002 Server not -initialized` and `-32001 Unknown MCP session` are never returned by any era. +The one `-32600` that is not `200` is a transport refusal rather than a verdict +on a request that was dispatched: a **handshake-era** request whose +`MCP-Protocol-Version` header names a version from neither era is refused with +`400` before dispatch, and `data.supported` lists every version this server +speaks. A `2026-07-28` request is never refused that way, because its body +settles what the header could not: an unknown version stated by both is +`-32022`, and a header that contradicts the body is `-32020`. + +That order holds generally. What the body says about itself is checked before +the headers that mirror it, so a request that is wrong in both ways is answered +with the protocol's verdict — an invalid `_meta` field is `-32602` and an +unsupported `_meta` version is `-32022` on either transport, never the `-32020` +the mirror would also have produced. + +Every handshake-era error a dispatched request produced stays HTTP `200` with +the JSON-RPC error in the body, which is the only thing a client of that era +reads. What the transport rejects before dispatch, in either era, carries its +own status: `415` for a content type that is not JSON, `411` for a missing +`Content-Length`, `413` for a body over the maximum size, and `400` for a parse +error, a JSON-RPC batch, or the unknown version header above. `-32002 Server +not initialized` and `-32001 Unknown MCP session` are never returned by any +era. + +A notification — a message with no `id` — is answered with nothing whatever +goes wrong inside it: over HTTP with an empty `202`, over stdio with silence. +The mirror headers are the exception, because they are a transport contract +rather than a verdict on the message: a notification whose headers do not +mirror its body is still refused with `400` and `-32020`. ### Transports @@ -161,8 +191,10 @@ initialized` and `-32001 Unknown MCP session` are never returned by any era. stream, `GET /mcp` and `HEAD /mcp` return `405` as well. - A request without an `MCP-Protocol-Version` header is treated as `2025-11-25`, this server's newest handshake version. The older spec suggests - assuming `2025-03-26`, a version this server does not speak; the value only - selects what is echoed and recorded, never what a method does. + assuming `2025-03-26`, a version this server does not speak. The header value + travels with the request as context and is available to the runtime; nothing + echoes it, records it, or acts on it, and no method behaves differently for + it. - JSON-RPC batches are rejected. - `notifications/cancelled` is accepted in both eras and answered with nothing, but it does not terminate the command the cancelled request started. A diff --git a/docs/telemetry.md b/docs/telemetry.md index fa8ae39..5873d50 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -71,9 +71,11 @@ behind on the server. a client and `tool_summary`/`session_end` once when it shuts down, however many clients it served in between. -`clientInfo` is whatever a client says it is, so both fields are narrowed to a -printable ASCII subset and truncated to 40 characters before they can become -event properties. Only `name` and `version` are read; a handshake-era +`client_name` and `client_version` are sanitized self-reported labels, not +identity. `clientInfo` is whatever a client says it is, so each field is +narrowed to letters, digits, spaces, and `. _ -` — dropping the characters that +make up an address or a path, so that neither can travel verbatim — and then +truncated to 40 characters. Only `name` and `version` are read; a handshake-era `tool_error` carries no identity at all, because the request that failed did not name one. diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 4780869..f1886a3 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -491,6 +491,16 @@ def test_http_modern_request_succeeds_with_mirrored_headers(self) -> None: self.assertEqual(listed_status, 200, listed) self.assert_modern_result(listed.get("result", {})) self.assertEqual(listed.get("result", {}).get("ttlMs"), 0) + self.assertEqual(listed.get("result", {}).get("cacheScope"), "private") + + # A tool that failed still answered completely: isError is a + # tools-domain verdict, resultType describes the envelope. + failed_status, failed = self.modern_http_post( + modern_request(4, "tools/call", {"name": "read_file", "arguments": {"path": "no/such/file.js"}}) + ) + self.assertEqual(failed_status, 200, failed) + self.assertTrue(failed.get("result", {}).get("isError"), failed) + self.assert_modern_result(failed.get("result", {})) # A notification mirrors its method too, and is still answered with an # empty 202 rather than a JSON-RPC response. @@ -626,6 +636,83 @@ def test_http_modern_protocol_errors_map_to_http_statuses(self) -> None: self.assertEqual(legacy_status, 200, legacy) self.assertEqual(legacy.get("error", {}).get("code"), -32601) + def test_http_answers_a_mirrored_future_version_with_the_modern_error(self) -> None: + """A version from neither era, stated consistently, is the body's fault. + + The header alone cannot say which era a client meant, so a header + naming an unknown version is a transport error — but not when the body + settles the question. Here `_meta` names the same future version, so + this is a modern request asking for a version this server does not + speak, and it is owed `-32022` with the versions that do work. + """ + + future = "2027-01-01" + status, response = self.raw_http_post( + json.dumps(modern_request(1, "tools/list", meta=modern_meta({META_PROTOCOL_VERSION: future}))).encode( + "utf-8" + ), + headers={"MCP-Protocol-Version": future, "Mcp-Method": "tools/list"}, + default_protocol_version=None, + ) + self.assertEqual(status, 400, response) + error = response.get("error", {}) + self.assertEqual(error.get("code"), -32022, response) + self.assertEqual(error.get("data", {}).get("supported"), [MODERN_PROTOCOL_VERSION]) + self.assertEqual(error.get("data", {}).get("received"), future) + + def test_http_answers_a_mistyped_meta_version_exactly_as_stdio_does(self) -> None: + """The `_meta` contract is checked before the headers that mirror it. + + A mistyped version fails the mirror as well — the header cannot equal + a number — but the transport must not answer for the body: the client + gets the same `-32602` it would get over stdio. + """ + + status, response = self.raw_http_post( + json.dumps(modern_request(1, "tools/list", meta=modern_meta({META_PROTOCOL_VERSION: 20260728}))).encode( + "utf-8" + ), + headers={"MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, "Mcp-Method": "tools/list"}, + default_protocol_version=None, + ) + self.assertEqual(status, 400, response) + error = response.get("error", {}) + self.assertEqual(error.get("code"), -32602, response) + self.assertEqual(error.get("data", {}).get("reason"), "protocol_version") + + def test_http_rejects_a_repeated_mirror_header(self) -> None: + """A mirror sent twice has no single value to check the body against.""" + + request = modern_request(1, "tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}) + status, response = self.raw_http_post( + json.dumps(request).encode("utf-8"), + headers={ + "MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, + "Mcp-Method": "tools/call", + "Mcp-Name": "read_file", + }, + repeated_headers=(("Mcp-Name", "read_file"),), + default_protocol_version=None, + ) + self.assertEqual(status, 400, response) + error = response.get("error", {}) + self.assertEqual(error.get("code"), -32020, response) + self.assertEqual(error.get("data", {}).get("header"), "Mcp-Name") + self.assertEqual(error.get("data", {}).get("reason"), "duplicate") + + def test_http_rejects_an_oversized_base64_sentinel(self) -> None: + """The sentinel is decoded before the body is read, so it is bounded.""" + + request = modern_request(1, "tools/call", {"name": "read_file", "arguments": {"path": "src/math.js"}}) + status, response = self.modern_http_post( + request, + headers={"Mcp-Name": f"=?base64?{'A' * 8193}?="}, + ) + self.assertEqual(status, 400, response) + error = response.get("error", {}) + self.assertEqual(error.get("code"), -32020, response) + self.assertEqual(error.get("data", {}).get("reason"), "oversized") + def test_http_preflight_advertises_the_mirror_headers(self) -> None: self.assertIsNotNone(self.client.url) parsed = urllib.parse.urlparse(str(self.client.url)) @@ -1120,23 +1207,63 @@ def test_http_serves_tools_without_a_handshake(self) -> None: finally: self.stop_process(process) - def test_initialize_notification_is_rejected(self) -> None: - status, response = self.raw_http_post( - json.dumps( - { - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2025-11-25", - "capabilities": {}, - "clientInfo": {"name": "invalid-notification", "version": "1"}, - }, - } - ).encode("utf-8"), + def test_initialize_notification_is_ignored(self) -> None: + """A handshake sent as a notification is answered with nothing. + + It cannot work — the negotiated version has nowhere to go — but + JSON-RPC forbids answering a notification at all, so the failure is + the client's to notice from the reply it never gets. + """ + + status, body = self.raw_notification_post( + { + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "invalid-notification", "version": "1"}, + }, + }, headers={"MCP-Protocol-Version": "2025-11-25"}, ) - self.assertEqual(status, 200) - self.assertEqual(response.get("error", {}).get("code"), -32600) + self.assertEqual(status, 202, body) + self.assertEqual(body, "") + + def test_a_notification_whose_meta_is_invalid_is_answered_with_silence(self) -> None: + """Nothing may be sent back for a notification, valid or not. + + The mirror headers are a transport-level contract and are still + enforced on a notification; the `_meta` this one carries is the + protocol's own business, and a fault in it is answered with nothing + on either transport. + """ + + notification = { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { + "requestId": "missing", + "_meta": modern_meta(drop=(META_CLIENT_CAPABILITIES,)), + }, + } + process = self.start_stdio_server() + try: + self.stdio_send(process, notification) + self.assert_no_stdio_response(process) + self.assertIsNone(process.poll(), "a rejected notification must not end the stdio session") + finally: + self.stop_process(process) + + status, body = self.raw_notification_post( + notification, + headers={ + "MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, + "Mcp-Method": "notifications/cancelled", + }, + ) + self.assertEqual(status, 202, body) + self.assertEqual(body, "") def test_initialize_with_newer_client_protocol_negotiates_server_version(self) -> None: payload = { @@ -1492,6 +1619,31 @@ def test_stdio_modern_meta_is_validated_before_the_method_runs(self) -> None: finally: self.stop_process(process) + def test_stdio_modern_meta_is_validated_without_walking_what_it_carries(self) -> None: + """Declared capabilities are checked for their type, never copied. + + A client controls how deeply nested its `_meta` objects are, so any + recursive handling of them is an outage a small request can cause: 600 + levels fit in well under 10 KB. + """ + + nested: dict[str, Any] = {} + node = nested + for _ in range(600): + node["child"] = {} + node = node["child"] + + process = self.start_stdio_server() + try: + listed = self.stdio_rpc( + process, + modern_request(1, "tools/list", meta=modern_meta({META_CLIENT_CAPABILITIES: nested})), + ) + self.assert_modern_result(listed.get("result", {})) + self.assertIsInstance(listed.get("result", {}).get("tools"), list) + finally: + self.stop_process(process) + def test_stdio_modern_era_still_reports_unimplemented_methods(self) -> None: process = self.start_stdio_server() try: @@ -1752,6 +1904,7 @@ def raw_http_post( content_type: str = "application/json", content_length: int | str | None = None, headers: dict[str, str] | None = None, + repeated_headers: tuple[tuple[str, str], ...] = (), path: str | None = None, default_protocol_version: str | None = "2025-11-25", ) -> tuple[int, dict[str, Any]]: @@ -1769,6 +1922,8 @@ def raw_http_post( connection.putheader("Content-Length", str(len(body) if content_length is None else content_length)) for name, value in (headers or {}).items(): connection.putheader(name, value) + for name, value in repeated_headers: + connection.putheader(name, value) connection.endheaders() if body: connection.send(body) @@ -1778,6 +1933,25 @@ def raw_http_post( finally: connection.close() + def raw_notification_post( + self, + notification: dict[str, Any], + *, + headers: dict[str, str] | None = None, + ) -> tuple[int, str]: + """POST a message with no id and return the status with the raw body.""" + + self.assertNotIn("id", notification) + parsed = urllib.parse.urlparse(str(self.client.url)) + status, _, body = self.raw_base_http_request( + f"{parsed.scheme}://{parsed.netloc}", + "POST", + parsed.path or "/mcp", + body=json.dumps(notification).encode("utf-8"), + headers={"Content-Type": "application/json", **(headers or {})}, + ) + return status, body + def modern_http_post( self, request: dict[str, Any], diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 8d0a714..efc5bf3 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -136,6 +136,28 @@ def test_disabled_session_never_reaches_the_sender(self) -> None: runtime.close() get_sender.assert_not_called() + def test_an_activating_request_writes_nothing_while_telemetry_is_off(self) -> None: + """Off means no sender and no install id: building an event reads one.""" + + saved_install_id = telemetry._install_id + telemetry._install_id = None + get_sender = Mock() + try: + with tempfile.TemporaryDirectory() as home: + with scrubbed_env(CODING_TOOLS_MCP_TELEMETRY="off", HOME=home): + with patch.object(telemetry, "_get_sender", get_sender): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + dispatch_rpc( + runtime, + {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + ) + runtime.close() + get_sender.assert_not_called() + self.assertFalse((Path(home) / ".coding-tools-mcp").exists(), "off must not create an install id") + finally: + telemetry._install_id = saved_install_id + def test_post_sends_nothing_when_disabled(self) -> None: with scrubbed_env(CODING_TOOLS_MCP_TELEMETRY="off"): with patch.object(telemetry, "urlopen", Mock()) as opener: @@ -304,26 +326,43 @@ def test_discover_probes_are_counted_and_do_not_need_a_handshake(self) -> None: self.assertEqual(end["legacy_requests"], 2) def test_self_reported_client_identity_is_sanitized(self) -> None: - sender = _CapturingSender() - with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): - with tempfile.TemporaryDirectory() as tmp: - runtime = Runtime(Path(tmp)) - _modern_request( - runtime, - "tools/call", - {"name": "read_file", "arguments": {"path": "missing.txt"}}, - client_info={ - "name": "evil\r\nclient\u4e2d\x07" + "x" * 200, - "version": "1.0\n", - "secret": "must-not-travel", - }, - ) - runtime.close() + # A client names itself; the label is ours. Anything that would turn + # one into free-form text, an address, or a path is dropped rather + # than escaped, and a name is never long enough to be an identifier. + cases = [ + ( + { + "name": "evil\r\nclient\u4e2d\x07" + "x" * 200, + "version": "1.0\n", + "secret": "must-not-travel", + }, + "evilclient" + "x" * 30, + "1.0", + ), + ({"name": "alice@example.com", "version": "2.0"}, "aliceexample.com", "2.0"), + ({"name": "/home/alice/repo", "version": "3.0"}, "homealicerepo", "3.0"), + ] + for client_info, expected_name, expected_version in cases: + with self.subTest(client_name=client_info["name"]): + sender = _CapturingSender() + with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + _modern_request( + runtime, + "tools/call", + {"name": "read_file", "arguments": {"path": "missing.txt"}}, + client_info=client_info, + ) + runtime.close() - error = _properties(_events_by_name(sender)["tool_error"][0]) - self.assertEqual(error["client_name"], "evilclient" + "x" * 30) - self.assertEqual(error["client_version"], "1.0") - self.assertNotIn("must-not-travel", json.dumps(sender.events)) + serialized = json.dumps(sender.events) + error = _properties(_events_by_name(sender)["tool_error"][0]) + self.assertEqual(error["client_name"], expected_name) + self.assertEqual(error["client_version"], expected_version) + self.assertNotIn("must-not-travel", serialized) + for character in ("@", "/"): + self.assertNotIn(character, str(error["client_name"])) def test_every_handshake_is_recorded_but_the_session_starts_once(self) -> None: sender = _CapturingSender() @@ -431,6 +470,27 @@ def test_each_protocol_choice_is_logged_once_to_stderr(self) -> None: ], ) + def test_a_method_name_cannot_write_a_second_line_into_the_log(self) -> None: + """The method comes off the wire, and the note is one line about it.""" + + stderr = io.StringIO() + method = "tools/list\r\ncoding-tools-mcp: forged operator note\x07" + with scrubbed_env(CODING_TOOLS_MCP_TELEMETRY="off"), contextlib.redirect_stderr(stderr): + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + _modern_request(runtime, method) + runtime.close() + + output = stderr.getvalue() + lines = [line for line in output.splitlines() if line.startswith("coding-tools-mcp:")] + self.assertEqual( + lines, + ["coding-tools-mcp: modern client request (tools/listcoding-tools-mcpforgedoperatornote)"], + output, + ) + self.assertNotIn("forged operator note", output) + self.assertNotIn("\x07", output) + class DocumentationDriftTests(unittest.TestCase): def test_documented_schema_matches_emitted_events(self) -> None: From 57f12cdf3906485d503d61652199c379fa5ab925 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:23:12 +0000 Subject: [PATCH 19/25] Refresh the compliance evidence for the v0.3 contract Co-authored-by: Cursor --- reports/compliance/latest.json | 12 +++++------- reports/compliance/latest.md | 12 +++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/reports/compliance/latest.json b/reports/compliance/latest.json index c22c284..1021468 100644 --- a/reports/compliance/latest.json +++ b/reports/compliance/latest.json @@ -1,22 +1,21 @@ { - "commit": "e9c9acf6535d6c2e86c050dcc66ef73db11eb038+dirty", - "contract": "coding-tools-mcp-v0.2", + "commit": "a03882dab098d2f0ed49afa4430054712d415e2b+dirty", + "contract": "coding-tools-mcp-v0.3", "dogfood": "passed", "e2e": "passed", - "elapsed_seconds": 62.588, + "elapsed_seconds": 100.168, "failures": [], "passed": true, "required_tools": { "apply_patch": "passed", "check_exec_environment": "passed", "exec_command": "passed", - "get_default_cwd": "passed", "git_blame": "passed", "git_diff": "passed", "git_log": "passed", "git_show": "passed", "git_status": "passed", - "kill_session": "passed", + "kill_command": "passed", "list_dir": "passed", "list_files": "passed", "read_file": "passed", @@ -24,13 +23,12 @@ "request_permissions": "passed", "search_text": "passed", "server_info": "passed", - "set_default_cwd": "passed", "view_image": "passed", "write_stdin": "passed" }, "security": "passed", "skipped": [], "suite": "all", - "tests_run": 84, + "tests_run": 127, "write_only": false } diff --git a/reports/compliance/latest.md b/reports/compliance/latest.md index 4915197..16417eb 100644 --- a/reports/compliance/latest.md +++ b/reports/compliance/latest.md @@ -1,18 +1,16 @@ # Compliance Report -- contract: `coding-tools-mcp-v0.2` -- commit: `e9c9acf6535d6c2e86c050dcc66ef73db11eb038+dirty` +- contract: `coding-tools-mcp-v0.3` +- commit: `a03882dab098d2f0ed49afa4430054712d415e2b+dirty` - suite: `all` - passed: `true` -- tests_run: `84` -- elapsed_seconds: `62.588` +- tests_run: `127` +- elapsed_seconds: `100.168` ## Required Tools - `server_info`: passed - `check_exec_environment`: passed -- `get_default_cwd`: passed -- `set_default_cwd`: passed - `read_file`: passed - `list_dir`: passed - `list_files`: passed @@ -20,7 +18,7 @@ - `apply_patch`: passed - `exec_command`: passed - `write_stdin`: passed -- `kill_session`: passed +- `kill_command`: passed - `read_output`: passed - `git_status`: passed - `git_diff`: passed From d09e90211c6f7b867d7f5a9e6aaf74a7d0e8a3fb Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:26:45 +0000 Subject: [PATCH 20/25] Preserve exact line boundaries when applying patches Both the file and the patch were split with str.splitlines(), which breaks on \x0b \x0c \x1c \x1d \x1e \x85 \u2028 \u2029 as well as \n, and rejoined with \n: any file holding one of those characters had it silently rewritten by any patch, and a context line holding one could never match. Splitting on \n is a bijection with the text, so the trailing-newline flag that made the end of a file unaddressable is gone with it. An empty context line is now read as the stripped ' ' it stands for. Co-authored-by: Cursor --- CHANGELOG.md | 17 +++ coding_tools_mcp/patching.py | 26 +++-- docs/tools-and-schemas.md | 3 + tests/compliance/test_runtime_helpers.py | 136 ++++++++++++++++++++++- 4 files changed, 173 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2093c0f..556a627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,23 @@ read before the first one committed and overwrite it silently. One runtime now owns the workspace, so its lock covers every client: the later patch is answered with a retryable conflict. +- `apply_patch` no longer silently rewrites lines it was not asked to touch. It + split both the file and the patch with `str.splitlines()`, which breaks on + `\x0b`, `\x0c`, `\x1c`, `\x1d`, `\x1e`, `\x85`, `\u2028`, and `\u2029` as well + as on `\n`, and then rejoined with `\n`. Any file containing one of those + characters had it replaced by a newline by any patch, including a patch that + changed an unrelated line, and a context line containing one could never + match. +- The number of newlines at the end of a file is now whatever the hunk says it + is. The trailing newline was captured from the file before applying and put + back unconditionally, so a hunk that added a final blank line had it removed + again and a hunk that removed the final newline had it restored. A file's + last line is now addressable like any other. +- A context line that is empty is accepted as the empty context line it stands + for, instead of failing the patch with `Invalid empty patch line`. V4A writes + such a line as a single space, and model output and intermediate layers + routinely strip that trailing space. Patch text ending in more than one + newline is likewise accepted. ## 0.2.3 - 2026-08-12 diff --git a/coding_tools_mcp/patching.py b/coding_tools_mcp/patching.py index 1fa5706..8dc9776 100644 --- a/coding_tools_mcp/patching.py +++ b/coding_tools_mcp/patching.py @@ -274,7 +274,13 @@ def _fsync_directory(directory: Path) -> None: def parse_patch(patch: str) -> list[PatchOperation]: - lines = patch.splitlines() + # split("\n") rather than splitlines(): a context line carrying a form feed + # or U+2028 must stay one patch line so it can match the file line it came + # from. The envelope closes on the last non-empty line because the patch + # text's own trailing newline(s) become trailing empty elements here. + lines = normalize_to_lf(patch).split("\n") + while lines and not lines[-1]: + lines.pop() if not lines or lines[0].strip() != "*** Begin Patch" or lines[-1].strip() != "*** End Patch": raise ToolFailure("PATCH_FAILED", "Patch must use *** Begin Patch / *** End Patch envelope.", category="validation") operations: list[PatchOperation] = [] @@ -331,8 +337,12 @@ def apply_update_hunks(content: str, hunks: list[list[str]], path: str = " ParsedHunk: if raw == "*** End of File": continue if not raw: - raise ToolFailure("PATCH_FAILED", "Invalid empty patch line.", category="validation") + # V4A spells an empty context line as a single space, which model + # output and intermediate layers routinely strip to "". + old.append("") + new.append("") + continue marker = raw[0] value = raw[1:] if marker in {" ", "-", "+"} else raw if marker == " ": diff --git a/docs/tools-and-schemas.md b/docs/tools-and-schemas.md index 76ae7ad..4217f91 100644 --- a/docs/tools-and-schemas.md +++ b/docs/tools-and-schemas.md @@ -77,6 +77,9 @@ All operations are parsed and matched before writes. Context must be unique. Files are prepared in their destination directories, fsynced, baseline-checked, and installed with atomic replacement. Multi-file failure restores prior files. Mode bits, BOM, and newline style are preserved; moves inherit source mode. +Lines are split on `\n` only, so a line containing another Unicode line +boundary (`\x0c`, `\u2028`, `\x85`, …) is one line to both the file and the +patch. A file's final newline is an ordinary line the hunk can add or remove. ## Model-ready examples diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index e879511..f559af1 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -19,7 +19,13 @@ from coding_tools_mcp import server as server_module from coding_tools_mcp import processes as processes_module from coding_tools_mcp import telemetry as telemetry_module -from coding_tools_mcp.patching import AtomicPatchCommitter, FileBaseline, StagedFile +from coding_tools_mcp.patching import ( + AtomicPatchCommitter, + FileBaseline, + StagedFile, + apply_update_hunks, + parse_patch, +) from coding_tools_mcp.server import ( LANDLOCK_ACCESS_FS_IOCTL_DEV, LANDLOCK_ACCESS_FS_TRUNCATE, @@ -1619,6 +1625,134 @@ def runtime_with_git_config(config: Path) -> Runtime: self.assertEqual(log.get("commits", [])[0].get("subject"), "baseline fixture") +class PatchLineFidelityTests(unittest.TestCase): + """A patch may only change the lines it names. + + The unit of a V4A patch is a line, and the file's line structure is exactly + what `split("\\n")` yields: the trailing empty element is the final newline. + Anything that re-derives that structure differently (splitlines(), a + remembered trailing-newline flag) either rewrites bytes no hunk touched or + makes the end of the file unaddressable. + """ + + def apply_via_runtime(self, name: str, original: bytes, patch: str) -> bytes: + with TemporaryDirectory() as tmp: + workspace = Path(tmp) + (workspace / name).write_bytes(original) + Runtime(workspace, permission_mode="dangerous").apply_patch({"patch": patch}) + return (workspace / name).read_bytes() + + def test_hunks_can_express_any_number_of_trailing_newlines(self) -> None: + for source_newlines in range(3): + for target_newlines in range(3): + with self.subTest(source=source_newlines, target=target_newlines): + content = "alpha" + "\n" * source_newlines + # Replacing the whole file means consuming every line it + # has, the trailing empty one included. + hunk = ( + ["-alpha"] + + ["-"] * source_newlines + + ["+beta"] + + ["+"] * target_newlines + ) + self.assertEqual( + apply_update_hunks(content, [hunk]), + "beta" + "\n" * target_newlines, + ) + + def test_runtime_writes_the_exact_bytes_the_hunk_describes(self) -> None: + stripped = self.apply_via_runtime( + "eof.txt", + b"alpha\n", + "*** Begin Patch\n*** Update File: eof.txt\n@@\n-alpha\n-\n+beta\n*** End Patch\n", + ) + self.assertEqual(stripped, b"beta") + + appended = self.apply_via_runtime( + "eof.txt", + b"alpha\n", + "*** Begin Patch\n*** Update File: eof.txt\n@@\n-alpha\n-\n+alpha\n+\n+\n*** End Patch\n", + ) + self.assertEqual(appended, b"alpha\n\n") + + def test_unicode_line_boundaries_survive_an_edit_to_another_line(self) -> None: + # str.splitlines() breaks on all of these and "\n".join would rewrite + # them to \n, corrupting lines the patch never mentioned. + for label, boundary in ( + ("vertical tab", "\x0b"), + ("form feed", "\x0c"), + ("file separator", "\x1c"), + ("group separator", "\x1d"), + ("record separator", "\x1e"), + ("next line", "\x85"), + ("line separator", "\u2028"), + ("paragraph separator", "\u2029"), + ): + with self.subTest(boundary=label): + untouched = f"first{boundary}still-the-first-line" + written = self.apply_via_runtime( + "boundary.txt", + f"{untouched}\nsecond\n".encode("utf-8"), + "*** Begin Patch\n*** Update File: boundary.txt\n@@\n-second\n+SECOND\n*** End Patch\n", + ) + self.assertEqual(written, f"{untouched}\nSECOND\n".encode("utf-8")) + + def test_context_lines_carrying_a_line_boundary_still_match(self) -> None: + written = self.apply_via_runtime( + "boundary.txt", + "keep\x0csame\nold\n".encode("utf-8"), + "*** Begin Patch\n*** Update File: boundary.txt\n@@\n keep\x0csame\n-old\n+new\n*** End Patch\n", + ) + self.assertEqual(written, "keep\x0csame\nnew\n".encode("utf-8")) + + def test_empty_context_line_is_read_as_a_stripped_space(self) -> None: + # V4A writes an empty context line as " "; model output and transports + # routinely strip that trailing space away. + self.assertEqual( + apply_update_hunks("alpha\n\nomega\n", [[" alpha", "", "-omega", "+OMEGA"]]), + "alpha\n\nOMEGA\n", + ) + written = self.apply_via_runtime( + "blank.txt", + b"alpha\n\nomega\n", + "*** Begin Patch\n*** Update File: blank.txt\n@@\n alpha\n\n-omega\n+OMEGA\n*** End Patch\n", + ) + self.assertEqual(written, b"alpha\n\nOMEGA\n") + + def test_envelope_tolerates_extra_trailing_newlines(self) -> None: + for trailing in range(3): + with self.subTest(trailing=trailing): + patch_text = ( + "*** Begin Patch\n*** Delete File: gone.txt\n*** End Patch" + "\n" * trailing + ) + operations = parse_patch(patch_text) + self.assertEqual([(op.kind, op.path) for op in operations], [("delete", "gone.txt")]) + + def test_empty_patch_text_is_still_rejected(self) -> None: + for patch_text in ("", "\n", "\n\n"): + with self.subTest(patch=patch_text): + with self.assertRaises(ToolFailure) as raised: + parse_patch(patch_text) + self.assertEqual(raised.exception.code, "PATCH_FAILED") + + def test_crlf_and_bom_files_keep_their_encoding(self) -> None: + written = self.apply_via_runtime( + "crlf.txt", + "\ufeffalpha\r\nold\r\nomega\r\n".encode("utf-8"), + "*** Begin Patch\n*** Update File: crlf.txt\n@@\n-old\n+new\n*** End Patch\n", + ) + self.assertEqual(written, "\ufeffalpha\r\nnew\r\nomega\r\n".encode("utf-8")) + + def test_insertion_into_an_empty_file_ends_with_a_newline(self) -> None: + self.assertEqual(apply_update_hunks("", [["+alpha"]]), "alpha\n") + + def test_appending_after_the_last_line_keeps_one_trailing_newline(self) -> None: + self.assertEqual( + apply_update_hunks("alpha\nomega\n", [[" omega", "+tail"]]), + "alpha\nomega\ntail\n", + ) + + class FakeReadonlyAnnotationTests(unittest.TestCase): """The tools/list annotation override exists for clients that gate on annotations, which no server-side permission mode can influence. It is only From c98fb8aadc49bfd3bfbf76db9f15870e5138bf6b Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:31:54 +0000 Subject: [PATCH 21/25] Surface error category, retryability, and command recovery hints retryable and category were only in structuredContent, which most clients never forward to the model, so a permanent failure read exactly like a transient one and dead command handles were retried until they timed out. The error text now states both, tells the model not to repeat a call that cannot succeed, and COMMAND_NOT_FOUND names exec_command as the way back. Co-authored-by: Cursor --- CHANGELOG.md | 10 +++ coding_tools_mcp/server.py | 21 ++++- coding_tools_mcp/tool_results.py | 14 ++++ docs/runtime-contract-v0.3.md | 11 +++ tests/compliance/test_runtime_helpers.py | 100 +++++++++++++++++++++++ 5 files changed, 154 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 556a627..36e7027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,16 @@ - Tool descriptions now direct remote clients to pass explicit `path`/`workdir` arguments and include concrete examples for patching and command continuation. +- Error text now names the error's category and whether it is retryable, and + tells a model not to repeat a call that cannot succeed. `retryable` and + `category` were only ever in `structuredContent`, which most clients do not + forward to the model, so a permanent failure was indistinguishable from a + transient one. +- `COMMAND_NOT_FOUND` from `write_stdin`, `kill_command`, and `read_output` now + explains that the handle expired or never existed, states the retention + window a finished command's output has, and names `exec_command` as the way + to recover. Retrying a dead handle is the single largest source of failed + `write_stdin` calls. - `kill_command` now declares `kill_wait_ms` (hard-kill escalation wait, default 2000 ms) in its input schema; previously the runtime honored it but schema validation rejected any call that passed it. diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 516b202..7f31bfc 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -198,6 +198,13 @@ class ModeCapabilities: MAX_RETAINED_OUTPUT_COMMANDS = 32 COMPLETED_COMMAND_TTL_SECONDS = 300 MAX_RUNTIME_OUTPUT_BYTES = 16 * 1024 * 1024 +_COMMAND_RECOVERY_HINT = ( + "This command_id has expired or never existed; a finished command keeps its" + f" output for {COMPLETED_COMMAND_TTL_SECONDS} seconds and only the last" + f" {MAX_RETAINED_OUTPUT_COMMANDS} commands are retained. Retrying with the" + " same command_id cannot succeed. Start the work again with exec_command and" + " use the command_id it returns." +) SHELL_CONTROL_TOKENS = {"|", "||", "&", "&&", ";", "(", ")"} REDIRECTION_TOKENS = {">", ">>", "<", "<>", ">&", "<&", "&>", "&>>"} HEREDOC_TOKENS = {"<<", "<<<"} @@ -2780,7 +2787,12 @@ def _get_output_command(self, command_id: str) -> CommandRun: with self.commands_lock: command = self.commands.get(command_id) or self.output_commands.get(command_id) if command is None: - raise ToolFailure("COMMAND_NOT_FOUND", "Output command not found.", category="runtime") + raise ToolFailure( + "COMMAND_NOT_FOUND", + "Output command not found.", + category="runtime", + details={"retry_hint": _COMMAND_RECOVERY_HINT}, + ) return command def _format_command_output(self, command: CommandRun, payload: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]: @@ -3064,7 +3076,12 @@ def _get_command(self, command_id: str) -> CommandRun: with self.commands_lock: command = self.commands.get(command_id) or self.output_commands.get(command_id) if command is None: - raise ToolFailure("COMMAND_NOT_FOUND", "Command not found; stdin access denied.", category="not_found") + raise ToolFailure( + "COMMAND_NOT_FOUND", + "Command not found; stdin access denied.", + category="not_found", + details={"retry_hint": _COMMAND_RECOVERY_HINT}, + ) return command def git_status(self, args: dict[str, Any]) -> dict[str, Any]: diff --git a/coding_tools_mcp/tool_results.py b/coding_tools_mcp/tool_results.py index 87e7da0..3030d46 100644 --- a/coding_tools_mcp/tool_results.py +++ b/coding_tools_mcp/tool_results.py @@ -49,6 +49,20 @@ def _render_error(payload: dict[str, Any]) -> str: code = str(error.get("code") or "TOOL_ERROR") message = str(error.get("message") or "Tool call failed.") lines = [f"{code}: {message}"] + # Most clients feed the model this text and nothing else, so terminality + # has to be stated here; leaving it in structuredContent alone is what + # lets a model retry a call that can never succeed. + retryable = error.get("retryable") + category = error.get("category") + facts: list[str] = [] + if isinstance(category, str) and category: + facts.append(f"Category: {category}.") + if isinstance(retryable, bool): + facts.append(f"Retryable: {'yes' if retryable else 'no'}.") + if not retryable: + facts.append("Do not repeat this call unchanged.") + if facts: + lines.append(" ".join(facts)) raw_details = error.get("details") details: dict[str, Any] = raw_details if isinstance(raw_details, dict) else {} retry_hint = details.get("retry_hint") diff --git a/docs/runtime-contract-v0.3.md b/docs/runtime-contract-v0.3.md index 5047525..5e7005b 100644 --- a/docs/runtime-contract-v0.3.md +++ b/docs/runtime-contract-v0.3.md @@ -279,6 +279,17 @@ Tool failures keep the same envelope with `isError: true`, a readable error in } ``` +A client that forwards only `content` to the model must still be able to tell +a transient failure from a permanent one, so the error text restates the +category and retryability under the code and message, and a terminal failure +says so outright: + +```text +COMMAND_NOT_FOUND: Command not found; stdin access denied. +Category: not_found. Retryable: no. Do not repeat this call unchanged. +Retry: This command_id has expired or never existed; … +``` + Known tool error codes include: ```json diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index f559af1..5924dd4 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -44,6 +44,7 @@ from coding_tools_mcp.tool_results import ( MODEL_TEXT_SAFETY_LIMIT_BYTES, make_tool_result, + render_tool_text, ) from tests.compliance.fixtures import git_fixture_preflight_error, init_git @@ -1753,6 +1754,105 @@ def test_appending_after_the_last_line_keeps_one_trailing_newline(self) -> None: ) +class ErrorTextTerminalityTests(unittest.TestCase): + """Whether a failure is worth retrying has to be in the model text. + + `retryable` and `category` live in structuredContent, but most clients + forward only the text content to the model. A model that cannot see + "this can never work" keeps calling a dead command handle. + """ + + @staticmethod + def error_text( + code: str, + message: str, + *, + category: str | None = "runtime", + retryable: bool | None = False, + details: dict[str, Any] | None = None, + ) -> str: + error: dict[str, Any] = {"code": code, "message": message, "details": details or {}} + if category is not None: + error["category"] = category + if retryable is not None: + error["retryable"] = retryable + return render_tool_text("write_stdin", {"ok": False, "error": error}, is_error=True) + + def test_first_line_is_still_code_and_message(self) -> None: + text = self.error_text("COMMAND_NOT_FOUND", "Command not found.") + self.assertEqual(text.splitlines()[0], "COMMAND_NOT_FOUND: Command not found.") + + def test_terminal_failure_says_so_and_forbids_a_bare_retry(self) -> None: + text = self.error_text("COMMAND_NOT_FOUND", "Command not found.", category="not_found") + self.assertIn("Category: not_found.", text) + self.assertIn("Retryable: no.", text) + self.assertIn("Do not repeat this call unchanged.", text) + + def test_retryable_failure_is_not_told_to_stop(self) -> None: + text = self.error_text( + "PATCH_CONFLICT", + "File changed while the patch was being prepared.", + category="conflict", + retryable=True, + ) + self.assertIn("Category: conflict.", text) + self.assertIn("Retryable: yes.", text) + self.assertNotIn("Do not repeat", text) + + def test_absent_fields_are_omitted_rather_than_guessed(self) -> None: + without_category = self.error_text("TOOL_ERROR", "Failed.", category=None) + self.assertNotIn("Category:", without_category) + self.assertIn("Retryable: no.", without_category) + + without_retryable = self.error_text("TOOL_ERROR", "Failed.", retryable=None) + self.assertIn("Category: runtime.", without_retryable) + self.assertNotIn("Retryable:", without_retryable) + self.assertNotIn("Do not repeat", without_retryable) + + bare = self.error_text("TOOL_ERROR", "Failed.", category=None, retryable=None) + self.assertEqual(bare, "TOOL_ERROR: Failed.") + + def test_retry_hint_still_follows_the_terminality_line(self) -> None: + text = self.error_text( + "COMMAND_NOT_FOUND", + "Command not found.", + category="not_found", + details={"retry_hint": "Start over with exec_command."}, + ) + self.assertEqual( + text.splitlines(), + [ + "COMMAND_NOT_FOUND: Command not found.", + "Category: not_found. Retryable: no. Do not repeat this call unchanged.", + "Retry: Start over with exec_command.", + ], + ) + + def test_dead_command_handle_tells_the_model_how_to_recover(self) -> None: + with TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode="dangerous") + for tool, arguments in ( + ("write_stdin", {"command_id": "cmd-does-not-exist", "chars": "y\n"}), + ("kill_command", {"command_id": "cmd-does-not-exist"}), + ("read_output", {"output_ref": "command:cmd-does-not-exist:stdout"}), + ): + with self.subTest(tool=tool): + result = runtime.call_tool(tool, arguments) + self.assertTrue(result["isError"]) + text = "\n".join( + item["text"] + for item in result["content"] + if item.get("type") == "text" + ) + self.assertIn("COMMAND_NOT_FOUND", text) + self.assertIn("Retryable: no", text) + self.assertIn("Do not repeat this call unchanged.", text) + self.assertIn("exec_command", text) + self.assertIn(str(server_module.COMPLETED_COMMAND_TTL_SECONDS), text) + self.assertIn(str(server_module.MAX_RETAINED_OUTPUT_COMMANDS), text) + self.assertIs(result["structuredContent"]["error"]["retryable"], False) + + class FakeReadonlyAnnotationTests(unittest.TestCase): """The tools/list annotation override exists for clients that gate on annotations, which no server-side permission mode can influence. It is only From 72371ab2f107e5f57faf0969dc5a66b07fc57fc1 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:41:59 +0000 Subject: [PATCH 22/25] Prepare the 0.3.0rc1 release candidate Co-authored-by: Cursor --- CHANGELOG.md | 2 +- coding_tools_mcp/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e7027..ee52d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.3.0 - 2026-08-12 +## 0.3.0rc1 - 2026-08-13 ### Changed diff --git a/coding_tools_mcp/__init__.py b/coding_tools_mcp/__init__.py index e850938..cc64740 100644 --- a/coding_tools_mcp/__init__.py +++ b/coding_tools_mcp/__init__.py @@ -1,3 +1,3 @@ """Coding Tools MCP server package.""" -__version__ = "0.3.0" +__version__ = "0.3.0rc1" diff --git a/pyproject.toml b/pyproject.toml index a73cbbb..67c3a7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "coding-tools-mcp" -version = "0.3.0" +version = "0.3.0rc1" description = "Workspace-confined coding tools exposed as an MCP server." requires-python = ">=3.11" dependencies = [ From 5d6e131afebd89f98438b1c1dca8d157c0713c8a Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:08:13 +0000 Subject: [PATCH 23/25] Prepare the 0.3.0 release Co-authored-by: Cursor --- CHANGELOG.md | 2 +- coding_tools_mcp/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee52d73..a8cb1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.3.0rc1 - 2026-08-13 +## 0.3.0 - 2026-08-13 ### Changed diff --git a/coding_tools_mcp/__init__.py b/coding_tools_mcp/__init__.py index cc64740..e850938 100644 --- a/coding_tools_mcp/__init__.py +++ b/coding_tools_mcp/__init__.py @@ -1,3 +1,3 @@ """Coding Tools MCP server package.""" -__version__ = "0.3.0rc1" +__version__ = "0.3.0" diff --git a/pyproject.toml b/pyproject.toml index 67c3a7e..a73cbbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "coding-tools-mcp" -version = "0.3.0rc1" +version = "0.3.0" description = "Workspace-confined coding tools exposed as an MCP server." requires-python = ">=3.11" dependencies = [ From b8d8bb38ebdaaa639ff41179ef21e6e61830b4f1 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:44:26 +0000 Subject: [PATCH 24/25] Fall back to trusted cmd.exe when PowerShell 7 is unavailable Windows string commands keep preferring a server-resolved PowerShell 7, but a missing or unverifiable unpinned pwsh no longer strands the agent: the server selects a trusted cmd.exe (COMSPEC or the Windows system directory), discloses the selection in server_info, check_exec_environment, and every exec_command result, and warns so the agent switches to cmd syntax. An explicit CODING_TOOLS_MCP_PWSH_PATH pin still fails instead of falling back. Hardening that review of the fallback surfaced: - Build the cmd.exe invocation as a raw /S /C command line. An argv list would be re-quoted with MS CRT rules, corrupting quoted arguments. - Slash-normalize Windows commands before the token-based policy scans: POSIX shlex eats backslashes, which let unquoted paths such as C:\Windows\...\powershell.exe -EncodedCommand slip past the nested interpreter and path checks. - Accept module-qualified spellings (Microsoft.PowerShell.Management\ Remove-Item) and path spellings (C:\...\format.com) in the PowerShell and cmd destructive/network scans. - Pin the shell selection per process behind a lock so concurrent execs agree on one interpreter and failed probes stop repeating; check_exec_environment re-resolves the pin on demand. - Stop applying the PowerShell scans to POSIX hosts, which had silently gated rm -r and ping there; POSIX policy is unchanged again. - Reduce safe-mode false positives: single-quoted PowerShell literals ('$5'), unpaired cmd percents (git log --format=%h), and CALL/FOR outside command position no longer require permissions. - Cover the real pwsh-hidden fallback chain end to end on the Windows runner, including quoted-argument fidelity through cmd.exe. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 9 + README.md | 20 +- README.zh-CN.md | 7 + coding_tools_mcp/processes.py | 144 ++++++- coding_tools_mcp/server.py | 177 +++++++-- docs/ci-and-tests.md | 2 + docs/limitations.md | 12 +- docs/runtime-contract-v0.2.md | 2 +- docs/runtime-contract-v0.3.md | 15 +- docs/troubleshooting.md | 20 + tests/compliance/test_windows_msvc_smoke.py | 5 +- tests/test_windows_pwsh.py | 399 +++++++++++++++++++- uv.lock | 59 +-- 13 files changed, 759 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8cb1ca..9baa2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### Changed + +- Windows string commands now prefer a trusted PowerShell 7 launcher and + automatically retain `cmd.exe` compatibility when `pwsh` is unavailable. + The selected shell is visible to agents, and safe mode conservatively gates + dynamic syntax for both interpreters. + ## 0.3.0 - 2026-08-13 ### Changed diff --git a/README.md b/README.md index 3fa6a12..4f4a7fe 100644 --- a/README.md +++ b/README.md @@ -74,17 +74,21 @@ walkthroughs, and troubleshooting live in [docs/quickstart.md](docs/quickstart.md) and [docs/mcp-client-config.md](docs/mcp-client-config.md). -On Windows, string commands run through PowerShell 7 (`pwsh`) with -`-NoLogo -NoProfile -NonInteractive`. Set -`CODING_TOOLS_MCP_PWSH_PATH` to an absolute trusted `pwsh.exe` path to pin -the launcher. The server reports `SHELL_NOT_FOUND` or -`SHELL_VERSION_UNSUPPORTED` instead of falling back to `cmd.exe`. +On Windows, string commands prefer PowerShell 7 (`pwsh`) with +`-NoLogo -NoProfile -NonInteractive`. If `pwsh` is unavailable, the server +automatically preserves command execution through a trusted `cmd.exe` +compatibility fallback. `server_info`, `check_exec_environment`, and each +`exec_command` result disclose the selected shell so agents can use the right +syntax. Set `CODING_TOOLS_MCP_PWSH_PATH` to an absolute trusted `pwsh.exe` path +to pin PowerShell; an invalid explicit pin is reported instead of ignored. Because PowerShell resolves commands at runtime, `safe` mode on Windows gates dynamic syntax — variables, splatting, call and dot-source operators, `::` member access, alias/expression evaluation, and nested shells — behind the `shell_expansion` and `inline_script` permissions. Literal commands are -unaffected; use `request_permissions` or `trusted` mode for the rest. +unaffected; use `request_permissions` or `trusted` mode for the rest. Under the +`cmd.exe` fallback, percent expansion, caret escaping, and `CALL`/`FOR` +evaluation likewise require `shell_expansion`. ## Seven things to try @@ -134,8 +138,8 @@ clipboard helpers, live health checks. English and 简体中文. debugger under a real POSIX PTY; `write_stdin` feeds it across turns; `read_output` pages long output; `kill_command` cleans up. Long-running processes are first-class, with deadline watchdogs and bounded buffers. -Windows uses PowerShell 7 for non-TTY commands; ConPTY remains a separate -limitation. +Windows prefers PowerShell 7 and otherwise uses the disclosed `cmd.exe` +fallback for non-TTY commands; ConPTY remains a separate limitation. **7. Give your own agent production-grade hands.** Building an agent loop with the Anthropic SDK or anything else? Don't hand-roll file and exec tools — diff --git a/README.zh-CN.md b/README.zh-CN.md index 6d39621..f5b948b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,6 +67,13 @@ npx coding-tools-mcp --stdio --workspace /path/to/repo # Node 工具链 [docs/quickstart.md](docs/quickstart.md) 与 [docs/mcp-client-config.md](docs/mcp-client-config.md)。 +在 Windows 上,字符串命令优先使用 PowerShell 7(`pwsh`);未安装时会自动回退到 +可信系统路径中的 `cmd.exe`,不会让 agent 因缺少额外 shell 而失去命令执行能力。 +`server_info`、`check_exec_environment` 和每次 `exec_command` 都会说明实际使用的 +shell,便于 agent 选择正确语法。运维方也可以通过 +`CODING_TOOLS_MCP_PWSH_PATH` 固定一个绝对的 `pwsh.exe` 路径;显式路径配置错误时 +会直接报错,不会掩盖配置问题。 + ## 七个值得一试的玩法 **1. 让 Claude Desktop 成为你的编程 agent。** diff --git a/coding_tools_mcp/processes.py b/coding_tools_mcp/processes.py index f2c62f3..5c28b4a 100644 --- a/coding_tools_mcp/processes.py +++ b/coding_tools_mcp/processes.py @@ -9,7 +9,7 @@ import time from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any, BinaryIO +from typing import Any, BinaryIO, Literal from .errors import ToolFailure from .textutils import DEFAULT_MAX_LINES, TextTruncation, truncate_text_tail @@ -23,6 +23,22 @@ COMMAND_HEAD_BUFFER_DIVISOR = 8 HARD_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) PWSH_PATH_ENV = "CODING_TOOLS_MCP_PWSH_PATH" +CMD_FALLBACK_WARNING = ( + "PowerShell 7 (pwsh) is unavailable; exec_command is using the cmd.exe " + "compatibility fallback. Commands use cmd.exe syntax, not PowerShell syntax. " + "Install PowerShell 7 or set CODING_TOOLS_MCP_PWSH_PATH to use PowerShell." +) + + +@dataclass(frozen=True) +class WindowsCommandShell: + """Trusted Windows command interpreter selected by the server.""" + + kind: Literal["pwsh", "cmd"] + executable: str + fallback: bool = False + fallback_reason: str | None = None + warning: str | None = None def _environment_value(env: Mapping[str, str], target: str) -> str | None: @@ -93,7 +109,7 @@ def resolve_pwsh() -> str: if executable is None: raise ToolFailure( "SHELL_NOT_FOUND", - "PowerShell 7 is required for Windows string commands, but pwsh was not found on PATH.", + "PowerShell 7 was not found on the server process PATH.", category="runtime", details={ "executable": "pwsh", @@ -114,6 +130,109 @@ def resolve_pwsh() -> str: return executable +def resolve_cmd() -> str: + """Resolve cmd.exe from trusted server-process Windows locations.""" + + raw_candidates: list[str] = [] + comspec = (_environment_value(os.environ, "COMSPEC") or "").strip().strip('"') + if comspec: + raw_candidates.append(comspec) + for variable in ("SYSTEMROOT", "WINDIR"): + root = (_environment_value(os.environ, variable) or "").strip().strip('"') + if root: + raw_candidates.append(ntpath.join(root, "System32", "cmd.exe")) + + seen: set[str] = set() + for raw_candidate in raw_candidates: + candidate = ntpath.normpath(ntpath.expandvars(raw_candidate)) + normalized = ntpath.normcase(candidate) + if normalized in seen: + continue + seen.add(normalized) + if ( + ntpath.isabs(candidate) + and ntpath.basename(candidate).lower() == "cmd.exe" + and os.path.isfile(candidate) + ): + return candidate + + executable = _find_windows_executable_on_path( + "cmd.exe", + _environment_value(os.environ, "PATH"), + ) + if executable is not None: + return executable + raise ToolFailure( + "SHELL_NOT_FOUND", + "Neither PowerShell 7 nor cmd.exe could be resolved for Windows string commands.", + category="runtime", + details={ + "executable": "cmd.exe", + "retry_hint": ( + "Restore the Windows command processor or install PowerShell 7 and add " + f"pwsh to PATH (or set {PWSH_PATH_ENV})." + ), + }, + ) + + +def resolve_windows_command_shell() -> WindowsCommandShell: + """Prefer PowerShell 7 and automatically retain cmd.exe compatibility.""" + + configured_pwsh = bool((_environment_value(os.environ, PWSH_PATH_ENV) or "").strip()) + try: + return WindowsCommandShell(kind="pwsh", executable=resolve_pwsh()) + except ToolFailure as exc: + # An explicit pin is operator intent. Falling back would hide a typo or + # silently ignore a version constraint the operator expected to hold. + if configured_pwsh: + raise + return WindowsCommandShell( + kind="cmd", + executable=resolve_cmd(), + fallback=True, + fallback_reason=exc.code, + warning=CMD_FALLBACK_WARNING, + ) + + +_selected_shell_lock = threading.Lock() +_selected_shell: WindowsCommandShell | ToolFailure | None = None + + +def selected_windows_command_shell(*, refresh: bool = False) -> WindowsCommandShell: + """Resolve the Windows shell once per process and pin the answer. + + Re-resolving per call would let a transient probe failure flip one exec to + cmd.exe while the next uses pwsh, and would let concurrent requests each + start their own multi-second version probe before command accounting sees + them. The lock serializes the first resolution; every later call reuses the + pinned selection (including a pinned failure) until ``refresh`` — used by + check_exec_environment so operators can pick up a newly installed shell + without restarting the server. + """ + + global _selected_shell + with _selected_shell_lock: + if refresh or _selected_shell is None: + try: + _selected_shell = resolve_windows_command_shell() + except ToolFailure as failure: + _selected_shell = failure + pinned = _selected_shell + if isinstance(pinned, ToolFailure): + raise pinned + return pinned + + +def _reset_selected_windows_command_shell() -> None: + """Drop the pinned shell selection (test seam).""" + + global _selected_shell + with _selected_shell_lock: + _selected_shell = None + + @functools.lru_cache(maxsize=16) def pwsh_major_version(executable: str) -> int: """Return and cache the major version reported by a pwsh executable.""" @@ -172,6 +291,20 @@ def build_pwsh_argv(executable: str, command: str) -> list[str]: ] +def build_cmd_command_line(executable: str, command: str) -> str: + """Build the exact cmd.exe command line, bypassing MS CRT argv quoting. + + Passing an argument list would make Popen serialize it with list2cmdline, + whose ``\\"`` escapes reach cmd.exe verbatim and corrupt any command that + contains quotes. ``/S /C`` is specified against a raw command line instead: + cmd strips the first and last quote and executes everything between them + unchanged, so the payload needs exactly one enclosing quote pair and no + further escaping. + """ + + return f'"{executable}" /D /V:OFF /S /C "{command}"' + + def terminate_process_group( process: subprocess.Popen[bytes], signum: signal.Signals, @@ -220,12 +353,17 @@ def spawn_process( env: dict[str, str], tty: bool, popen_kwargs: dict[str, Any], + windows_shell: WindowsCommandShell | None = None, ) -> tuple[subprocess.Popen[bytes], int | None]: """Spawn a pipe-backed or true POSIX PTY-backed process.""" if not tty: if os.name == "nt" and shell and isinstance(command, str): - command = build_pwsh_argv(resolve_pwsh(), command) + selected_shell = windows_shell or selected_windows_command_shell() + if selected_shell.kind == "pwsh": + command = build_pwsh_argv(selected_shell.executable, command) + else: + command = build_cmd_command_line(selected_shell.executable, command) shell = False process = subprocess.Popen( command, diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index cd156e2..ffaaffe 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -62,6 +62,8 @@ COMMAND_BUFFER_BYTES, COMMAND_HEAD_BUFFER_DIVISOR, CommandRun, + WindowsCommandShell, + selected_windows_command_shell, spawn_process, start_reader_threads, start_command_watchdog, @@ -188,7 +190,7 @@ class ModeCapabilities: re.I, ) POWERSHELL_NETWORK_RE = re.compile( - r"(?:^|[;&|{}\r\n])\s*(?:Invoke-WebRequest|Invoke-RestMethod|Start-BitsTransfer|" + r"(?:^|[;&|{}\r\n])\s*(?:[\w.]+[\\/])?(?:Invoke-WebRequest|Invoke-RestMethod|Start-BitsTransfer|" r"Test-NetConnection|Test-Connection|Resolve-DnsName|iwr|irm|tnc|ping(?:\.exe)?|" r"nslookup(?:\.exe)?|tracert(?:\.exe)?)\b|\b(?:System\.)?Net\.", re.I, @@ -198,11 +200,19 @@ class ModeCapabilities: r"(^|\s)(sudo|su|chmod\s+-R|chown\s+-R|mkfs|mount|umount|find\b[^;&|]*\s-delete\b|git\b[^;&|]*\breset\s+--hard\b|git\b[^;&|]*\bclean\s+-[^\s]*[fx][^\s]*|rm\s+-[^\s]*r[^\s]*f|rm\s+-[^\s]*f[^\s]*r)\b", re.I, ) +# A module-qualified spelling (Microsoft.PowerShell.Management\Remove-Item) +# invokes the same cmdlet, so the command-position match accepts an optional +# Module\ prefix in both PowerShell scans above and below. POWERSHELL_DESTRUCTIVE_RE = re.compile( - r"(?:^|[;&|{}\r\n])\s*(?:Remove-Item|rm|ri|del|erase|rmdir|rd)\b" + r"(?:^|[;&|{}\r\n])\s*(?:[\w.]+[\\/])?(?:Remove-Item|rm|ri|del|erase|rmdir|rd)\b" r"(?=[^;&|{}\r\n]*\s-(?:r|re|rec|recu|recur|recurs|recurse)\b)", re.I, ) +# Single-quoted PowerShell strings are inert text ('' is the only escape), so +# the dynamic-syntax scan drops them before matching; a literal '$5' or 'a::b' +# argument is not expansion. Backticks never reach this far in safe mode +# because SHELL_EXPANSION_RE already gates them. +POWERSHELL_SINGLE_QUOTED_RE = re.compile(r"'[^']*(?:''[^']*)*'") # PowerShell resolves commands at runtime, so scanning for cmdlet names cannot # see through a variable, a splatted parameter set, a redefined alias, or a # .NET member call. Any of those constructs makes the destructive and network @@ -217,6 +227,26 @@ class ModeCapabilities: r"Add-Type|Set-Alias|New-Alias|sal|nal)\b)", re.I, ) +# cmd.exe also resolves command text at runtime. Paired %...% expansion, +# CALL/FOR double evaluation, and caret escaping can hide the literal command +# from the scans above, so safe mode treats those constructs like shell +# expansion. A lone % cannot expand on a command line (echo 100%, +# git log --format=%h), and CALL/FOR only evaluate at command position +# (echo call for help stays literal), so neither is gated. +CMD_DYNAMIC_RE = re.compile( + r"(?P%[^%\r\n]+%)" + r"|(?P\^)" + r"|(?P(?:^|[&|()\r\n])\s*(?:call|for)\b)", + re.I, +) +CMD_DESTRUCTIVE_RE = re.compile( + r"(?:^|[&|()\r\n])\s*(?:(?:del|erase|rd|rmdir)\b" + r"(?=[^&|()\r\n]*\s/(?:s|s[q]?|q[s]))" + # format.com and diskpart are real executables, so a path spelling such as + # C:\Windows\System32\format.com invokes them just as well as the bare name. + r"|(?:[^\s&|()\r\n]*[\\/])?(?:format(?:\.com)?|diskpart)(?:\.exe)?(?=$|[\s&|()<>\r\n]))", + re.I, +) MAX_HTTP_REQUEST_BYTES = 1_048_576 EXEC_PREVIEW_BYTES = 4096 MAX_ACTIVE_COMMANDS = 16 @@ -1570,8 +1600,8 @@ def git_path_filter(self, raw_path: str) -> str: return "." return self.resolve_for_write(raw_path).display - def _exec_environment_summary(self) -> dict[str, Any]: - return { + def _exec_environment_summary(self, *, refresh_command_shell: bool = False) -> dict[str, Any]: + summary: dict[str, Any] = { "workspace": str(self.workspace.root), "permission_mode": self.permission_mode, "network_allowed": self.allow_network, @@ -1580,6 +1610,18 @@ def _exec_environment_summary(self) -> dict[str, Any]: "tmpdir": str(self.command_tmp_dir()), "cache_dir": str(self.cache_dir), } + if os.name == "nt": + try: + summary["command_shell"] = windows_command_shell_payload( + selected_windows_command_shell(refresh=refresh_command_shell) + ) + except ToolFailure as exc: + summary["command_shell"] = { + "available": False, + "error_code": exc.code, + "message": exc.message, + } + return summary def _landlock_enforced(self, landlock: dict[str, Any]) -> bool: return bool(landlock.get("available")) and self.landlock_enabled() @@ -1693,6 +1735,10 @@ def server_info(self, args: dict[str, Any]) -> dict[str, Any]: def check_exec_environment(self, args: dict[str, Any]) -> dict[str, Any]: landlock = landlock_status_payload() + # The explicit diagnostic re-resolves the pinned Windows shell so an + # operator who just installed pwsh sees (and activates) it here without + # restarting the server. + summary = self._exec_environment_summary(refresh_command_shell=True) warnings: list[str] = [] if not landlock.get("available"): warnings.append("Linux Landlock filesystem confinement is unavailable") @@ -1702,9 +1748,16 @@ def check_exec_environment(self, args: dict[str, Any]) -> dict[str, Any]: warnings.append( "tools/list annotations are faked as read-only; apply_patch and exec_command still mutate and execute" ) + command_shell = summary.get("command_shell") + if isinstance(command_shell, dict): + shell_warning = command_shell.get("warning") + if isinstance(shell_warning, str): + warnings.append(shell_warning) + elif command_shell.get("available") is False: + warnings.append(str(command_shell.get("message") or "Windows command shell is unavailable")) return { "ok": True, - **self._exec_environment_summary(), + **summary, "landlock_enabled": self._landlock_enforced(landlock), "landlock_abi": landlock.get("abi_version"), "global_tmp_write": self.global_tmp_write_policy(), @@ -2382,11 +2435,12 @@ def exec_command(self, args: dict[str, Any]) -> dict[str, Any]: workdir = self.resolve_existing(str(workdir_arg)) if not workdir.path.is_dir(): raise ToolFailure("NOT_A_DIRECTORY", "workdir is not a directory.", category="validation") - self._check_command_policy(cmd, args) + tty = bool(args.get("tty", False)) + windows_shell = selected_windows_command_shell() if os.name == "nt" and not tty else None + self._check_command_policy(cmd, args, windows_shell=windows_shell) timeout_ms = int(args.get("timeout_ms", 30000)) yield_ms = int(args.get("yield_time_ms", 10000)) max_output_bytes = int(args.get("max_output_bytes", 65536)) - tty = bool(args.get("tty", False)) stdin_text = str(args.get("stdin", "")) env = self._command_env(args.get("env", {})) start = time.time() @@ -2438,11 +2492,20 @@ def exec_command(self, args: dict[str, Any]) -> dict[str, Any]: env=env, tty=tty, popen_kwargs=popen_extra, + windows_shell=windows_shell, ) + command_warnings = [ + warning + for warning in ( + landlock_warning, + windows_shell.warning if windows_shell is not None else None, + ) + if warning + ] command = self._make_command( process, timeout_at=deadline, - warnings=[landlock_warning] if landlock_warning else None, + warnings=command_warnings, pty_master_fd=pty_master_fd, ) with self.commands_lock: @@ -2485,6 +2548,8 @@ def finish() -> dict[str, Any]: # terminated/timeout) so exec, polling, and kill paths agree. payload = command.snapshot_since_cursor(max_output_bytes) payload["elapsed_ms"] = int((time.time() - start) * 1000) + if windows_shell is not None: + payload["command_shell"] = windows_command_shell_payload(windows_shell) self._add_exec_diagnostics(payload) return self._format_command_output(command, payload, args) @@ -2509,10 +2574,30 @@ def finish() -> dict[str, Any]: return finish() time.sleep(0.02) - def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: + def _check_command_policy( + self, + cmd: str, + args: dict[str, Any], + *, + windows_shell: WindowsCommandShell | None = None, + ) -> None: if self.dangerously_skip_all_permissions: return - self._check_command_paths(cmd) + compact = " ".join(cmd.split()).lower() + # POSIX shlex treats backslash as an escape and silently eats it, which + # blinds every token-based check to unquoted Windows paths such as + # C:\Windows\System32\...\powershell.exe. Backslash is a path separator + # on both Windows shells (their escapes are backtick and caret), so the + # token scans run on a slash-normalized copy there. + scan_cmd = cmd.replace("\\", "/") if windows_shell is not None else cmd + if windows_shell is not None and windows_shell.kind == "cmd" and CMD_DESTRUCTIVE_RE.search(cmd): + raise ToolFailure( + "PERMISSION_REQUIRED", + "Destructive commands are blocked without explicit permission.", + category="permission", + details={"permission": "destructive_command", "command": compact}, + ) + self._check_command_paths(scan_cmd) env = args.get("env", {}) if isinstance(env, dict) and any( is_filtered_env_var(str(key), str(value)) for key, value in env.items() @@ -2524,7 +2609,7 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: details={"permission": "sensitive_env", "env_keys": sorted(str(key) for key in env)}, ) if not self.capabilities.inline_script: - inline_script = inline_script_command(cmd) + inline_script = inline_script_command(scan_cmd) if inline_script is not None: raise ToolFailure( "PERMISSION_REQUIRED", @@ -2532,7 +2617,12 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: category="permission", details={"permission": INLINE_SCRIPT_PERMISSION, **inline_script}, ) - compact = " ".join(cmd.split()).lower() + uses_powershell = powershell_executes_string_commands(windows_shell) + uses_cmd = windows_shell is not None and windows_shell.kind == "cmd" + # The PowerShell scans model PowerShell semantics; running them against + # a POSIX shell or cmd.exe command would gate rm -r and ping on hosts + # where those regexes were never the contract. + scan_powershell = uses_powershell if not self.capabilities.shell_expansion and SHELL_EXPANSION_RE.search(cmd): raise ToolFailure( "PERMISSION_REQUIRED", @@ -2547,15 +2637,20 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: category="permission", details={"permission": "destructive_command", "command": compact}, ) - if DESTRUCTIVE_RE.search(cmd) or POWERSHELL_DESTRUCTIVE_RE.search(cmd): + if ( + DESTRUCTIVE_RE.search(cmd) + or (scan_powershell and POWERSHELL_DESTRUCTIVE_RE.search(cmd)) + ): raise ToolFailure( "PERMISSION_REQUIRED", "Destructive commands are blocked without explicit permission.", category="permission", details={"permission": "destructive_command", "command": compact}, ) - network_command = NETWORK_RE.search(cmd) or POWERSHELL_NETWORK_RE.search(cmd) - if not self.allow_network and network_command and not is_literal_network_reference_command(cmd): + network_command = NETWORK_RE.search(cmd) or ( + scan_powershell and POWERSHELL_NETWORK_RE.search(cmd) + ) + if not self.allow_network and network_command and not is_literal_network_reference_command(scan_cmd): raise ToolFailure( "PERMISSION_REQUIRED", "Network access is denied by default.", @@ -2565,7 +2660,7 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: # Runs last so a command the scans above already recognized keeps its # precise permission label; this is the catch-all for the PowerShell # syntax that makes those scans unsound in the first place. - if not self.capabilities.shell_expansion and powershell_executes_string_commands(): + if not self.capabilities.shell_expansion and uses_powershell: construct = powershell_dynamic_construct(cmd) if construct is not None: raise ToolFailure( @@ -2580,6 +2675,20 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: "command": compact, }, ) + if not self.capabilities.shell_expansion and uses_cmd: + construct = cmd_dynamic_construct(cmd) + if construct is not None: + raise ToolFailure( + "PERMISSION_REQUIRED", + "cmd.exe dynamic syntax requires explicit permission because expansion, " + "double evaluation, and escaping can hide the command from policy scans.", + category="permission", + details={ + "permission": "shell_expansion", + "construct": construct, + "command": compact, + }, + ) def _add_exec_diagnostics(self, payload: dict[str, Any]) -> None: diagnostics = exec_output_diagnostics(payload) @@ -3978,19 +4087,45 @@ def is_inspectable_path_argument(token: str) -> bool: return "." in PurePosixPath(normalized).name -def powershell_executes_string_commands() -> bool: - """True when this host runs string commands through PowerShell 7. +def windows_command_shell_payload(shell: WindowsCommandShell) -> dict[str, Any]: + payload: dict[str, Any] = { + "available": True, + "kind": shell.kind, + "executable": shell.executable, + "fallback": shell.fallback, + } + if shell.fallback_reason is not None: + payload["fallback_reason"] = shell.fallback_reason + if shell.warning is not None: + payload["warning"] = shell.warning + return payload + - Mirrors the spawn decision in processes.spawn_process, which wraps Windows - string commands in pwsh. Command policy has to agree with that decision: +def powershell_executes_string_commands( + windows_shell: WindowsCommandShell | None = None, +) -> bool: + """True when the selected Windows string-command shell is PowerShell 7. + + Command policy has to mirror the selection passed to processes.spawn_process: PowerShell syntax is only worth gating where PowerShell is the interpreter. + The host fallback keeps direct policy-helper tests backward compatible. """ + if windows_shell is not None: + return windows_shell.kind == "pwsh" return os.name == "nt" def powershell_dynamic_construct(command: str) -> str | None: - match = POWERSHELL_DYNAMIC_RE.search(command) + scannable = POWERSHELL_SINGLE_QUOTED_RE.sub("''", command) + match = POWERSHELL_DYNAMIC_RE.search(scannable) + if match is None: + return None + return match.lastgroup + + +def cmd_dynamic_construct(command: str) -> str | None: + match = CMD_DYNAMIC_RE.search(command) if match is None: return None return match.lastgroup diff --git a/docs/ci-and-tests.md b/docs/ci-and-tests.md index 6e632a7..dd32ee0 100644 --- a/docs/ci-and-tests.md +++ b/docs/ci-and-tests.md @@ -116,6 +116,8 @@ Windows reports unsupported TTY requests explicitly, force-kills a background command without relying on POSIX `SIGKILL`, initializes Visual Studio with `vcvarsall.bat x64`, checks the narrow default `core` environment, and confirms that `--shell-env-inherit all` can compile and run a single-file `cl.exe` smoke. +It also exercises PowerShell 7 selection, the trusted `cmd.exe` compatibility +fallback, and the shell-specific safe-mode policy gates. Manual SWE-bench workflow: diff --git a/docs/limitations.md b/docs/limitations.md index 8fd8af8..0170fb2 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -6,11 +6,14 @@ - Non-Linux platforms or Linux kernels without Landlock are not production targets for `exec_command` without an external sandbox. - This build uses real POSIX PTYs but does not implement Windows ConPTY; `tty=true` returns `TTY_UNSUPPORTED` on Windows. -- Windows string commands require PowerShell 7 (`pwsh`) and run with +- Windows string commands prefer PowerShell 7 (`pwsh`) and run it with `-NoLogo -NoProfile -NonInteractive`. Operators may pin an absolute trusted executable with `CODING_TOOLS_MCP_PWSH_PATH`; otherwise the server searches absolute entries on its own process `PATH` while excluding the current - directory tree. There is no `cmd.exe` fallback. + directory tree. If no usable unpinned `pwsh` is found, the server + automatically falls back to a trusted `cmd.exe` resolved from the server + environment or Windows system directory. An invalid explicit PowerShell pin + remains an error. - Windows `safe` mode cannot statically decide what PowerShell dynamic syntax resolves to, so variables (`$`), splatting (`@`), the call and dot-source operators, .NET member access (`::`), and alias or expression evaluation @@ -20,6 +23,11 @@ `request_permissions` or `trusted` mode for commands that need them. Command scanning is not a sandbox: this build has no OS-level confinement on Windows, so `safe` mode there is a best-effort gate rather than a boundary. +- The `cmd.exe` compatibility fallback disables AutoRun and delayed expansion. + In `safe` mode, percent expansion, caret escaping, and `CALL`/`FOR` evaluation + require `shell_expansion`; recursive `del`/`rmdir`, `format`, and `diskpart` + remain permission-gated. These checks are also best-effort rather than an + OS-level boundary. - Portable filesystems do not provide a transaction across unrelated directories. `apply_patch` keeps same-directory backups and rolls back the full staged set, but a storage failure that also prevents rollback is surfaced diff --git a/docs/runtime-contract-v0.2.md b/docs/runtime-contract-v0.2.md index 680ad40..fd85d8b 100644 --- a/docs/runtime-contract-v0.2.md +++ b/docs/runtime-contract-v0.2.md @@ -114,7 +114,7 @@ Tool failures keep the same envelope with `isError: true`, a readable error in Known tool error codes include: ```json -["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "SHELL_NOT_FOUND", "SHELL_VERSION_UNSUPPORTED", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] +["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] ``` Error categories are `validation`, `security`, `permission`, `runtime`, diff --git a/docs/runtime-contract-v0.3.md b/docs/runtime-contract-v0.3.md index 5e7005b..eabb0fd 100644 --- a/docs/runtime-contract-v0.3.md +++ b/docs/runtime-contract-v0.3.md @@ -293,7 +293,7 @@ Retry: This command_id has expired or never existed; … Known tool error codes include: ```json -["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] +["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "SHELL_NOT_FOUND", "SHELL_VERSION_UNSUPPORTED", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] ``` Error categories are `validation`, `security`, `permission`, `runtime`, @@ -336,6 +336,13 @@ bounded, all of them per workspace rather than per client. Completed commands have a TTL. POSIX `tty=true` uses a real pseudo-terminal; Windows reports `TTY_UNSUPPORTED` in this build instead of pretending pipes are a TTY. +Windows string commands prefer a server-resolved PowerShell 7 (`pwsh`) and +automatically use a trusted `cmd.exe` compatibility fallback when no usable +unpinned PowerShell is available. An invalid +`CODING_TOOLS_MCP_PWSH_PATH` remains an error. Windows `exec_command` results +include a structured `command_shell`; fallback results also include a warning +so an agent can use cmd syntax rather than retrying PowerShell syntax. + ## HTTP authentication Non-loopback deployment requires bearer or OAuth authentication unless the @@ -375,7 +382,8 @@ Annotations: `{"title":"Server info","readOnlyHint":true,"destructiveHint":false Returns server version, `supported_protocol_versions`, workspace, fixed tool count, auth state, permission mode, runtime directories, project-context -metadata, exec policy, and the static retained-output budget. It reports no +metadata, exec policy, the selected Windows `command_shell` when applicable, +and the static retained-output budget. It reports no per-session value and no runtime counter: there is no session, and how often a budget was hit is a property of the process rather than an answer to whichever client asked. Those counters travel with telemetry. @@ -386,7 +394,8 @@ Inputs: none. Annotations: `{"title":"Check exec environment","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. -Returns lightweight policy and Landlock status without running active probes. +Returns lightweight policy, Landlock status, and the selected Windows command +shell. A `cmd.exe` fallback is repeated in `warnings`. ### read_file diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f0bc4c9..f724d56 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -22,6 +22,26 @@ If `exec_command` returns a warning about Linux Landlock being unavailable, the If an older client or server reports `SANDBOX_UNAVAILABLE` as an error, upgrade to the current behavior or run on a Landlock-capable Linux kernel. +## Windows Command Shell + +Windows string commands prefer PowerShell 7 (`pwsh`). If it is not installed or +an unpinned copy cannot be verified, the server automatically uses the trusted +Windows `cmd.exe` compatibility fallback. The selected interpreter appears as +`command_shell` in `server_info`, `check_exec_environment`, and +`exec_command`; fallback results also include a warning so the agent can switch +to cmd syntax. + +The selection is resolved once and pinned for the server process, so concurrent +commands always agree on one interpreter. `check_exec_environment` re-resolves +the pin: after installing PowerShell 7, run that tool (or restart the server) to +leave the `cmd.exe` fallback. + +To require a specific PowerShell installation, set +`CODING_TOOLS_MCP_PWSH_PATH` to its absolute `pwsh.exe` path. A bad explicit pin +returns `SHELL_NOT_FOUND` or `SHELL_VERSION_UNSUPPORTED` instead of falling back. +If neither interpreter can be resolved, restore `cmd.exe` or install PowerShell +7. + ## Command Hangs Or Times Out If the result returns `status: "running"`, poll with `write_stdin` using empty `chars`, or terminate with `kill_command`. Command deadlines still apply when the client stops polling. diff --git a/tests/compliance/test_windows_msvc_smoke.py b/tests/compliance/test_windows_msvc_smoke.py index 6e40269..9a93ac5 100644 --- a/tests/compliance/test_windows_msvc_smoke.py +++ b/tests/compliance/test_windows_msvc_smoke.py @@ -132,7 +132,10 @@ def test_inherit_all_preserves_msvc_environment_for_single_file_compile(self) -> run_result = client.call_tool( "exec_command", { - "cmd": "hello.exe", + # PowerShell intentionally does not search the current + # directory for executables; this spelling also works + # unchanged under the cmd.exe compatibility fallback. + "cmd": r".\hello.exe", "timeout_ms": 30000, "yield_time_ms": 30000, "max_output_bytes": 20000, diff --git a/tests/test_windows_pwsh.py b/tests/test_windows_pwsh.py index 732e96a..dae98f8 100644 --- a/tests/test_windows_pwsh.py +++ b/tests/test_windows_pwsh.py @@ -11,10 +11,21 @@ from coding_tools_mcp.errors import ToolFailure from coding_tools_mcp.server import Runtime, ShellEnvPolicy +PWSH_SHELL = processes.WindowsCommandShell( + kind="pwsh", + executable=r"C:\Program Files\PowerShell\7\pwsh.exe", +) +CMD_SHELL = processes.WindowsCommandShell( + kind="cmd", + executable=r"C:\Windows\System32\cmd.exe", + fallback=True, +) + class WindowsPowerShellSpawnTests(unittest.TestCase): def tearDown(self) -> None: processes.pwsh_major_version.cache_clear() + processes._reset_selected_windows_command_shell() def test_windows_string_command_uses_server_resolved_noninteractive_pwsh(self) -> None: captured: dict[str, object] = {} @@ -64,6 +75,63 @@ def fake_popen(command: object, **kwargs: object) -> FakeProcess: self.assertIs(captured["shell"], False) self.assertEqual(captured["env"], {"Path": attacker_path}) + def test_windows_string_command_falls_back_to_trusted_cmd(self) -> None: + captured: dict[str, object] = {} + + class FakeProcess: + pass + + def fake_popen(command: object, **kwargs: object) -> FakeProcess: + captured["command"] = command + captured.update(kwargs) + return FakeProcess() + + trusted = r"C:\Windows\System32\cmd.exe" + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict( + processes.os.environ, + { + "COMSPEC": trusted, + "Path": r"C:\Windows\System32", + "SystemRoot": r"C:\Windows", + }, + clear=True, + ), + patch.object(processes.os.path, "isfile", side_effect=lambda path: path == trusted), + patch.object(processes.subprocess, "Popen", side_effect=fake_popen), + ): + process, pty_fd = processes.spawn_process( + "echo ok", + cwd=r"C:\workspace", + shell=True, + env={"Path": r"C:\workspace\bin"}, + tty=False, + popen_kwargs={}, + ) + + self.assertIsInstance(process, FakeProcess) + self.assertIsNone(pty_fd) + self.assertEqual( + captured["command"], + f'"{trusted}" /D /V:OFF /S /C "echo ok"', + ) + self.assertIs(captured["shell"], False) + self.assertEqual(captured["env"], {"Path": r"C:\workspace\bin"}) + + def test_cmd_command_line_preserves_embedded_quotes_verbatim(self) -> None: + # An argv list would be re-serialized with MS CRT rules and reach + # cmd.exe as \" sequences, corrupting any quoted argument. The raw + # /S /C command line must carry the payload byte-for-byte. + command = 'type "C:\\Program Files\\notes.txt" > "out dir\\copy.txt"' + line = processes.build_cmd_command_line(r"C:\Windows\System32\cmd.exe", command) + self.assertEqual( + line, + '"C:\\Windows\\System32\\cmd.exe" /D /V:OFF /S /C ' + '"type "C:\\Program Files\\notes.txt" > "out dir\\copy.txt""', + ) + def test_windows_pwsh_resolution_skips_current_directory_entries(self) -> None: trusted = r"C:\Program Files\PowerShell\7\pwsh.exe" malicious = r"C:\workspace\pwsh.exe" @@ -100,17 +168,44 @@ def test_windows_pwsh_rejects_invalid_explicit_path(self) -> None: self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") - def test_windows_string_command_requires_pwsh(self) -> None: + def test_invalid_explicit_pwsh_pin_does_not_fall_back(self) -> None: with ( - patch.object(processes.os, "name", "nt"), - patch.object(processes.os, "getcwd", return_value=r"C:\server"), - patch.dict(processes.os.environ, {"Path": r"C:\Windows\System32"}, clear=True), + patch.dict( + processes.os.environ, + {processes.PWSH_PATH_ENV: r".\pwsh.exe"}, + clear=True, + ), patch.object(processes.os.path, "isfile", return_value=False), + patch.object(processes, "resolve_cmd") as resolve_cmd, ): with self.assertRaises(ToolFailure) as raised: - processes.resolve_pwsh() + processes.resolve_windows_command_shell() self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") + resolve_cmd.assert_not_called() + + def test_windows_shell_falls_back_when_pwsh_is_missing(self) -> None: + cmd = r"C:\Windows\System32\cmd.exe" + with ( + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict( + processes.os.environ, + { + "COMSPEC": cmd, + "Path": r"C:\Windows\System32", + "SystemRoot": r"C:\Windows", + }, + clear=True, + ), + patch.object(processes.os.path, "isfile", side_effect=lambda path: path == cmd), + ): + selected = processes.resolve_windows_command_shell() + + self.assertEqual(selected.kind, "cmd") + self.assertEqual(selected.executable, cmd) + self.assertTrue(selected.fallback) + self.assertEqual(selected.fallback_reason, "SHELL_NOT_FOUND") + self.assertIn("cmd.exe syntax", selected.warning or "") def test_windows_string_command_rejects_powershell_older_than_7(self) -> None: executable = r"C:\PowerShell\6\pwsh.exe" @@ -126,6 +221,42 @@ def test_windows_string_command_rejects_powershell_older_than_7(self) -> None: self.assertEqual(raised.exception.code, "SHELL_VERSION_UNSUPPORTED") + def test_unpinned_unsupported_pwsh_falls_back_to_cmd(self) -> None: + pwsh = r"C:\PowerShell\6\pwsh.exe" + cmd = r"C:\Windows\System32\cmd.exe" + with ( + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict( + processes.os.environ, + { + "COMSPEC": cmd, + "Path": r"C:\PowerShell\6;C:\Windows\System32", + }, + clear=True, + ), + patch.object( + processes.os.path, + "isfile", + side_effect=lambda path: path in {pwsh, cmd}, + ), + patch.object(processes, "pwsh_major_version", return_value=6), + ): + selected = processes.resolve_windows_command_shell() + + self.assertEqual(selected.kind, "cmd") + self.assertEqual(selected.fallback_reason, "SHELL_VERSION_UNSUPPORTED") + + def test_windows_shell_reports_error_when_neither_interpreter_exists(self) -> None: + with ( + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict(processes.os.environ, {"Path": r"C:\empty"}, clear=True), + patch.object(processes.os.path, "isfile", return_value=False), + ): + with self.assertRaises(ToolFailure) as raised: + processes.resolve_windows_command_shell() + + self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") + @unittest.skipUnless(os.name == "nt", "requires Windows PowerShell 7") def test_runtime_executes_bare_powershell_syntax(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -150,6 +281,150 @@ def test_runtime_executes_bare_powershell_syntax(self) -> None: self.assertEqual(result.get("exit_code"), 0, result) self.assertIn("PS_MAJOR=7", str(result.get("stdout", ""))) + @unittest.skipUnless(os.name == "nt", "requires Windows cmd.exe") + def test_runtime_cmd_fallback_is_visible_and_executes(self) -> None: + cmd = os.environ["COMSPEC"] + selected = processes.WindowsCommandShell( + kind="cmd", + executable=cmd, + fallback=True, + fallback_reason="SHELL_NOT_FOUND", + warning=processes.CMD_FALLBACK_WARNING, + ) + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime( + Path(tmp), + permission_mode="trusted", + shell_env_policy=ShellEnvPolicy(inherit="all"), + ) + try: + with patch.object( + server, "selected_windows_command_shell", return_value=selected + ): + result = runtime.exec_command( + { + "cmd": "echo CMD_FALLBACK_OK", + "timeout_ms": 30_000, + "yield_time_ms": 30_000, + "verbosity": "full", + } + ) + finally: + runtime.close() + + self.assertEqual(result.get("exit_code"), 0, result) + self.assertIn("CMD_FALLBACK_OK", str(result.get("stdout", ""))) + self.assertEqual(result.get("command_shell", {}).get("kind"), "cmd") + self.assertTrue(result.get("command_shell", {}).get("fallback")) + self.assertTrue( + any("cmd.exe compatibility fallback" in warning for warning in result.get("warnings", [])), + result, + ) + + @unittest.skipUnless(os.name == "nt", "requires Windows cmd.exe") + def test_runtime_real_cmd_fallback_without_pwsh_on_path(self) -> None: + # No mocked resolver or Popen: hide every PATH entry that offers + # pwsh.exe, let selection fall back to the real cmd.exe, and run a + # command whose quoted argument would be corrupted by argv re-quoting. + def offers_pwsh(entry: str) -> bool: + candidate = entry.strip().strip('"') + if not candidate: + return False + try: + return (Path(candidate) / "pwsh.exe").is_file() + except OSError: + return False + + stripped_path = ";".join( + entry for entry in os.environ.get("PATH", "").split(";") if not offers_pwsh(entry) + ) + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "has space.txt").write_text("FALLBACK_QUOTES_OK", encoding="utf-8") + runtime = Runtime( + Path(tmp), + permission_mode="trusted", + shell_env_policy=ShellEnvPolicy(inherit="all"), + ) + try: + with patch.dict(os.environ, {"PATH": stripped_path}, clear=False): + os.environ.pop(processes.PWSH_PATH_ENV, None) + processes._reset_selected_windows_command_shell() + result = runtime.exec_command( + { + "cmd": 'type "has space.txt"', + "timeout_ms": 30_000, + "yield_time_ms": 30_000, + "verbosity": "full", + } + ) + finally: + processes._reset_selected_windows_command_shell() + runtime.close() + + self.assertEqual(result.get("exit_code"), 0, result) + self.assertIn("FALLBACK_QUOTES_OK", str(result.get("stdout", ""))) + self.assertEqual(result.get("command_shell", {}).get("kind"), "cmd") + self.assertTrue(result.get("command_shell", {}).get("fallback"), result) + + def test_server_info_and_exec_check_disclose_cmd_fallback(self) -> None: + selected = processes.WindowsCommandShell( + kind="cmd", + executable=r"C:\Windows\System32\cmd.exe", + fallback=True, + fallback_reason="SHELL_NOT_FOUND", + warning=processes.CMD_FALLBACK_WARNING, + ) + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + try: + summary = {"command_shell": server.windows_command_shell_payload(selected)} + with patch.object(runtime, "_exec_environment_summary", return_value=summary): + info = runtime.server_info_payload() + check = runtime.check_exec_environment({}) + finally: + runtime.close() + + self.assertEqual(info.get("command_shell", {}).get("kind"), "cmd") + self.assertTrue(info.get("command_shell", {}).get("fallback")) + self.assertTrue( + any("cmd.exe compatibility fallback" in warning for warning in check.get("warnings", [])), + check, + ) + + +class WindowsShellSelectionCacheTests(unittest.TestCase): + def tearDown(self) -> None: + processes._reset_selected_windows_command_shell() + + def test_selection_is_pinned_for_the_process(self) -> None: + with patch.object( + processes, "resolve_windows_command_shell", return_value=PWSH_SHELL + ) as resolver: + first = processes.selected_windows_command_shell() + second = processes.selected_windows_command_shell() + + self.assertIs(first, PWSH_SHELL) + self.assertIs(second, PWSH_SHELL) + resolver.assert_called_once() + + def test_selection_failure_is_pinned_until_refresh(self) -> None: + failure = ToolFailure("SHELL_NOT_FOUND", "no shell", category="runtime") + with patch.object( + processes, + "resolve_windows_command_shell", + side_effect=[failure, CMD_SHELL], + ) as resolver: + with self.assertRaises(ToolFailure): + processes.selected_windows_command_shell() + # A pinned failure must not re-probe on the exec path. + with self.assertRaises(ToolFailure): + processes.selected_windows_command_shell() + self.assertEqual(resolver.call_count, 1) + refreshed = processes.selected_windows_command_shell(refresh=True) + + self.assertIs(refreshed, CMD_SHELL) + self.assertEqual(resolver.call_count, 2) + class WindowsPowerShellPolicyTests(unittest.TestCase): def test_safe_mode_blocks_powershell_network_commands_without_url_literals(self) -> None: @@ -167,6 +442,8 @@ def test_safe_mode_blocks_powershell_network_commands_without_url_literals(self) "Resolve-DnsName example.com", "[System.Net.Dns]::GetHostAddresses('example.com')", "Write-Output ok\nInvoke-WebRequest -Uri example.com", + # Module-qualified invocation runs the same cmdlet. + "Microsoft.PowerShell.Utility\\Invoke-WebRequest -Uri example.com", ) with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="safe") @@ -174,7 +451,7 @@ def test_safe_mode_blocks_powershell_network_commands_without_url_literals(self) for command in commands: with self.subTest(command=command): with self.assertRaises(ToolFailure) as raised: - runtime._check_command_policy(command, {}) + runtime._check_command_policy(command, {}, windows_shell=PWSH_SHELL) self.assertEqual(raised.exception.details.get("permission"), "network") finally: runtime.close() @@ -192,6 +469,8 @@ def test_safe_mode_blocks_recursive_powershell_deletion_aliases_and_abbreviation "del -Recurse .", "rmdir -r .", "Write-Output ok\r\nRemove-Item -Recurse .", + # Module-qualified invocation runs the same cmdlet. + "Microsoft.PowerShell.Management\\Remove-Item -Recurse -Force build", ) with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="safe") @@ -199,7 +478,7 @@ def test_safe_mode_blocks_recursive_powershell_deletion_aliases_and_abbreviation for command in commands: with self.subTest(command=command): with self.assertRaises(ToolFailure) as raised: - runtime._check_command_policy(command, {}) + runtime._check_command_policy(command, {}, windows_shell=PWSH_SHELL) self.assertEqual( raised.exception.details.get("permission"), "destructive_command", @@ -216,11 +495,10 @@ def assert_policy(self, command: str, *, mode: str = "safe") -> ToolFailure | No with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode=mode) try: - with patch.object(server, "powershell_executes_string_commands", return_value=True): - try: - runtime._check_command_policy(command, {}) - except ToolFailure as failure: - return failure + try: + runtime._check_command_policy(command, {}, windows_shell=PWSH_SHELL) + except ToolFailure as failure: + return failure finally: runtime.close() return None @@ -252,6 +530,10 @@ def test_safe_mode_gates_nested_shells_that_smuggle_encoded_scripts(self) -> Non "pwsh -Command Invoke-WebRequest example.com", "powershell.exe -c whoami", "cmd /c del /s /q C:\\data", + # Unquoted absolute interpreter paths must not slip past the gate + # just because POSIX tokenization eats backslashes. + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -EncodedCommand SQBuAHYAbwBrAGUA", + "C:\\Windows\\System32\\cmd.exe /c del /s /q data", ): with self.subTest(command=command): failure = self.assert_policy(command) @@ -267,6 +549,9 @@ def test_safe_mode_still_allows_literal_powershell_commands(self) -> None: "git config user.email dev@example.com", ".\\build.exe --release", "Get-Content README.md | Select-String planet.txt", + # Single-quoted PowerShell strings are inert: no expansion happens. + "Write-Output '$5'", + "git commit -m 'refs #12 :: costs $5'", ): with self.subTest(command=command): self.assertIsNone(self.assert_policy(command), f"{command!r} was blocked in safe mode") @@ -281,14 +566,98 @@ def test_network_scan_does_not_flag_words_ending_in_net(self) -> None: self.assertIsNotNone(server.POWERSHELL_NETWORK_RE.search("New-Object System.Net.WebClient")) self.assertIsNotNone(server.POWERSHELL_NETWORK_RE.search("[Net.Dns]::GetHostAddresses('a')")) - def test_posix_hosts_keep_their_existing_expansion_policy(self) -> None: + +class PosixPolicyIsolationTests(unittest.TestCase): + """Without a Windows shell the PowerShell and cmd.exe scans must stay off: + they would otherwise rewrite the POSIX policy contract (rm -r without -f + and ping were never gated there).""" + + def assert_policy(self, command: str) -> ToolFailure | None: with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="safe") try: - with patch.object(server, "powershell_executes_string_commands", return_value=False): - runtime._check_command_policy("echo $HOME", {}) + try: + runtime._check_command_policy(command, {}) + except ToolFailure as failure: + return failure finally: runtime.close() + return None + + def test_posix_hosts_keep_their_existing_expansion_policy(self) -> None: + self.assertIsNone(self.assert_policy("echo $HOME")) + + def test_posix_hosts_do_not_inherit_powershell_scans(self) -> None: + for command in ("rm -r build", "ping example.com", "Remove-Item -Recurse ."): + with self.subTest(command=command): + self.assertIsNone( + self.assert_policy(command), + f"{command!r} was blocked by a Windows-only scan on POSIX", + ) + + +class WindowsCmdPolicyTests(unittest.TestCase): + def assert_policy(self, command: str, *, mode: str = "safe") -> ToolFailure | None: + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode=mode) + try: + try: + runtime._check_command_policy(command, {}, windows_shell=CMD_SHELL) + except ToolFailure as failure: + return failure + finally: + runtime.close() + return None + + def test_safe_mode_blocks_recursive_cmd_deletion(self) -> None: + for command in ( + "del /s /q data", + "rmdir /s /q build", + "format.com D:", + "format D:", + "diskpart", + # Path spellings invoke the same executables. + "C:\\Windows\\System32\\format.com D:", + ): + with self.subTest(command=command): + failure = self.assert_policy(command) + self.assertIsNotNone(failure) + assert failure is not None + self.assertEqual(failure.details.get("permission"), "destructive_command") + + def test_safe_mode_gates_cmd_dynamic_syntax(self) -> None: + cases = { + "%COMSPEC% /c echo ok": "expansion", + "echo %PATH%": "expansion", + "c^u^r^l example.com": "escape", + "call tool.cmd": "dynamic_eval", + "for %f in (*.txt) do type %f": "dynamic_eval", + } + for command, construct in cases.items(): + with self.subTest(command=command): + failure = self.assert_policy(command) + self.assertIsNotNone(failure) + assert failure is not None + self.assertEqual(failure.details.get("permission"), "shell_expansion") + self.assertEqual(failure.details.get("construct"), construct) + + def test_safe_mode_allows_literal_cmd_commands(self) -> None: + for command in ( + "echo ok", + "git status", + "hello.exe", + "cl.exe /nologo hello.c", + # A lone % cannot expand on a cmd command line. + "git log --format=%h", + "echo 100%", + # CALL and FOR only evaluate at command position. + "echo call for help", + ): + with self.subTest(command=command): + self.assertIsNone(self.assert_policy(command), f"{command!r} was blocked in safe mode") + + def test_trusted_mode_allows_cmd_dynamic_syntax(self) -> None: + self.assertIsNone(self.assert_policy("%COMSPEC% /c echo ok", mode="trusted")) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index bb03c28..a6bffa2 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ wheels = [ [[package]] name = "coding-tools-mcp" -version = "0.2.2" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pyjwt" }, @@ -61,7 +61,6 @@ desktop = [ ] dev = [ { name = "mypy" }, - { name = "pyyaml" }, { name = "ruff" }, { name = "typing-extensions" }, ] @@ -76,7 +75,6 @@ requires-dist = [ { name = "psutil", marker = "extra == 'desktop'", specifier = ">=7.0,<8" }, { name = "pyjwt", specifier = ">=2.8" }, { name = "pyside6", marker = "extra == 'desktop'", specifier = ">=6.8,<6.9" }, - { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15,<0.16" }, { name = "typing-extensions", marker = "extra == 'dev'", specifier = ">=4.12" }, ] @@ -393,61 +391,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0f/5d8c6da7586e57ee032643e0c0e62335ef1a1add1a980160ddd1654f1d8d/PySide6_Essentials-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07", size = 72191029, upload-time = "2025-03-27T12:15:10.425Z" }, ] -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - [[package]] name = "ruff" version = "0.15.17" From d697470efe274c7937cda6d5c68cb63650029553 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:47:33 +0000 Subject: [PATCH 25/25] Keep the PR branch's workflow triggers verbatim The maintainer push credential has no workflows permission, and the trigger update this merge carried already lives on main; merging the PR restores it without the fork branch having to change the file. Co-authored-by: Claude Fable 5 --- .github/workflows/compliance.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index 37ccdeb..6445449 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -2,9 +2,9 @@ name: compliance on: push: - branches: [main, "recover-*", "release/**", "hotfix/**"] + branches: [main, "recover-*"] pull_request: - branches: [main, "release/**"] + branches: [main] workflow_dispatch: workflow_call: