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/CHANGELOG.md b/CHANGELOG.md index 7730583..9baa2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ### 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 + - **Breaking:** command handles are now named `command_id`; `kill_session` is now `kill_command`; retained output references use `command::stdout|stderr`. The former command `session_id`, @@ -17,10 +26,217 @@ 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:** `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, + 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. +- 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. +- `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 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`. +- 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. +- **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`. +- **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 + 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 + 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 + 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. +- **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 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 + above with what to do about it. The v0.2 contract is kept, frozen, as the + 0.2.x record. + +### 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 + `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`, + `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`; `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 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. +- `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 + +### 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 aa2d625..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. @@ -34,6 +36,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/README.md b/README.md index a178309..4f4a7fe 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) @@ -66,11 +66,30 @@ 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). +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. Under the +`cmd.exe` fallback, percent expansion, caret escaping, and `CALL`/`FOR` +evaluation likewise require `shell_expansion`. + ## Seven things to try **1. Make Claude Desktop your coding agent.** The config above is all it @@ -116,9 +135,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 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 — @@ -137,13 +158,14 @@ 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; +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 +212,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 22acce3..f5b948b 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) @@ -60,11 +60,20 @@ 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)。 +在 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。** @@ -126,13 +135,14 @@ 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` 则是完整稳定的机器 -结果。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 +184,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 22004e6..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 @@ -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` @@ -32,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 @@ -53,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 @@ -62,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 a339764..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 @@ -76,7 +78,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 +144,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 +151,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 +174,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/__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/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/coding_tools_mcp/processes.py b/coding_tools_mcp/processes.py index 382f019..5c28b4a 100644 --- a/coding_tools_mcp/processes.py +++ b/coding_tools_mcp/processes.py @@ -1,19 +1,308 @@ 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 +from typing import Any, BinaryIO, Literal from .errors import ToolFailure from .textutils import DEFAULT_MAX_LINES, TextTruncation, truncate_text_tail 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) +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: + 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 was not found on the server process 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 + + +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.""" + + 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 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( @@ -64,10 +353,18 @@ 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): + 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, cwd=cwd, @@ -123,6 +420,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 +441,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 +623,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 +733,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/protocol.py b/coding_tools_mcp/protocol.py index 2b5baa4..b5e3905 100644 --- a/coding_tools_mcp/protocol.py +++ b/coding_tools_mcp/protocol.py @@ -1,12 +1,83 @@ from __future__ import annotations +import base64 +from collections.abc import Mapping +from dataclasses import dataclass from typing import Any 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",) +KNOWN_PROTOCOL_VERSIONS = (*MODERN_PROTOCOL_VERSIONS, *LEGACY_PROTOCOL_VERSIONS) +LEGACY_ERA = "legacy" +MODERN_ERA = "modern" + +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 +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 = "?=" +# 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" +MODERN_METHODS = frozenset( + { + DISCOVER_METHOD, + "notifications/cancelled", + "ping", + "tools/list", + "tools/call", + } +) +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: + """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, 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 def jsonrpc_error( @@ -56,16 +127,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: @@ -73,55 +146,357 @@ 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 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)] + 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 + 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. + + 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 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. 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"] + 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}, + ) + 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"}, + ) + 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=bounded_client_info(declared) if isinstance(declared, dict) else None, + ) + + +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, 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 -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. - Handshake state lives 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. + The era is decided first, from the request itself: a modern request states + 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, and may report the legacy version their framing negotiated through + ``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) - 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 - 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) - 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, request_id=request_id) + 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: - raise JsonRpcError(-32601, f"Unknown method: {method}") - if request_id is None: + context = RequestContext( + era=LEGACY_ERA, + 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 {"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: + if is_notification: + return None 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 == DISCOVER_METHOD: + return runtime.discover_payload() + 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``. + + 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. ``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": + validate_initialize_request(request) + negotiated_version = validate_initialize_params(params) + client_info = params.get("clientInfo") + return runtime.initialize( + client_info if isinstance(client_info, dict) else None, + negotiated_version, + ) + 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) + 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 dfe3c71..ffaaffe 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -60,26 +60,37 @@ from .processes import ( HARD_KILL_SIGNAL, COMMAND_BUFFER_BYTES, + COMMAND_HEAD_BUFFER_DIVISOR, CommandRun, + WindowsCommandShell, + selected_windows_command_shell, spawn_process, start_reader_threads, start_command_watchdog, terminate_process_group, ) from .protocol import ( - PROTOCOL_VERSION, - SUPPORTED_PROTOCOL_VERSIONS, + HEADER_MISMATCH, + KNOWN_PROTOCOL_VERSIONS, + LATEST_LEGACY_PROTOCOL_VERSION, + MODERN_ERA, + MODERN_PROTOCOL_VERSIONS, + UNSUPPORTED_PROTOCOL_VERSION, + RequestContext, dispatch_rpc, jsonrpc_error, - protocol_version_is_supported, + legacy_protocol_version_is_supported, + protocol_version_is_known, + request_era, response_id, + validate_mirror_headers, + validate_modern_meta, validate_rpc_envelope, ) from .project_context import ProjectContext, load_project_context 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 @@ -178,17 +189,77 @@ 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*(?:[\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, +) 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, ) +# 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*(?:[\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 +# 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, +) +# 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 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 = {"<<", "<<<"} @@ -564,23 +635,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.", @@ -618,7 +672,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 +699,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, @@ -1104,12 +1162,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: @@ -1120,14 +1174,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): @@ -1150,17 +1200,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) @@ -1235,6 +1274,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: @@ -1321,23 +1385,18 @@ 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._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 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.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} @@ -1353,7 +1412,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]: @@ -1375,36 +1434,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 @@ -1439,19 +1519,59 @@ 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) + 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, protocol_version: str = LATEST_LEGACY_PROTOCOL_VERSION) -> dict[str, Any]: + """Build the handshake payload for one negotiated version. + + 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(), + } + + 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}}, - "serverInfo": { - "name": SERVER_NAME, - "title": SERVER_TITLE, - "version": __version__, - }, "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": [ @@ -1469,22 +1589,19 @@ 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]: - 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, @@ -1493,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() @@ -1505,9 +1634,8 @@ def server_info_payload(self) -> dict[str, Any]: "server": SERVER_NAME, "title": SERVER_TITLE, "version": __version__, - "protocol_version": self.protocol_version, + "supported_protocol_versions": list(KNOWN_PROTOCOL_VERSIONS), **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, @@ -1521,6 +1649,13 @@ 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, + }, "endpoint_path": MCP_ENDPOINT_PATH, "project_context": { "root_instruction_files": [item.path for item in self.project_context.root_files], @@ -1536,7 +1671,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 {} @@ -1546,16 +1681,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: @@ -1584,7 +1712,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 = { @@ -1599,7 +1727,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]: @@ -1607,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") @@ -1616,41 +1748,43 @@ 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(), "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: + def emit_tool_trace( + self, + name: str, + args: dict[str, Any], + payload: dict[str, Any], + started_at: float, + *, + context: RequestContext | None = None, + ) -> None: 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 @@ -2301,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() @@ -2357,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: @@ -2386,10 +2530,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: @@ -2408,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) @@ -2432,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() @@ -2447,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", @@ -2455,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", @@ -2470,20 +2637,58 @@ 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 (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}, ) - 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 ( + 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.", 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 uses_powershell: + 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, + }, + ) + 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) @@ -2680,6 +2885,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: @@ -2734,7 +2940,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]: @@ -2755,12 +2966,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 +3046,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] @@ -2855,7 +3071,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,28 +3080,43 @@ 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) + 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 ref_stream == "full": - warnings.append("legacy full output_ref defaults to stdout; use output_refs for stable stream paging") + 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}", @@ -2895,8 +3126,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, @@ -2991,27 +3224,17 @@ 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: 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]: @@ -3117,7 +3340,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 @@ -3694,6 +3919,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 @@ -3848,6 +4087,50 @@ def is_inspectable_path_argument(token: str) -> bool: return "." in PurePosixPath(normalized).name +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 + + +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: + 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 + + def is_literal_network_reference_command(command: str) -> bool: try: tokens = shlex_split(command) @@ -4514,12 +4797,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}, @@ -4602,6 +4879,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}, @@ -4723,7 +5001,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, + "supportedProtocolVersions": list(KNOWN_PROTOCOL_VERSIONS), "server": { "name": SERVER_NAME, "title": SERVER_TITLE, @@ -4732,7 +5010,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": { @@ -4749,12 +5027,37 @@ 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 +# 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__}" @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) @@ -4791,14 +5094,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] @@ -4819,7 +5122,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() @@ -4847,7 +5150,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 @@ -4882,13 +5185,6 @@ 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): - self.send_rpc_error( - -32600, - "Unsupported MCP protocol version", - data={"supported": list(SUPPORTED_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) @@ -4913,7 +5209,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): @@ -4929,60 +5227,99 @@ 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: + # 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) + if era == MODERN_ERA: + duplicate = self.duplicated_mirror_header() + if duplicate is not None: self.send_rpc_error( - -32001, "Unknown MCP session", status=404, request_id=response_id(request) + HEADER_MISMATCH, + f"{duplicate} must appear exactly once", + request_id=response_id(request), + data={"header": duplicate, "reason": "duplicate"}, ) 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")) + # 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, + 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) - 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 + response = self.handle_rpc(request, transport_protocol_version=protocol_version) 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 - self.send_json(response) + 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. + """ - def handle_rpc(self, request: dict[str, Any]) -> dict[str, Any] | None: + 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], + *, + 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)) @@ -5346,10 +5683,10 @@ 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", + "Accept, Authorization, Content-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name", ) def send_json( @@ -5365,8 +5702,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) @@ -5382,16 +5717,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() @@ -5546,20 +5878,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/telemetry.py b/coding_tools_mcp/telemetry.py index 3f32d25..7711baa 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,26 @@ 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 + " ._-") +# 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" +_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 +99,74 @@ 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 _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. + + 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 +279,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 +302,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 +313,75 @@ 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_label(method)})") + if method == DISCOVER_METHOD: + note_first_appearance("discover-probe", f"{DISCOVER_METHOD} probe") + # ``_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: + """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 +413,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 +423,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 +452,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/tool_results.py b/coding_tools_mcp/tool_results.py index 719b0c3..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") @@ -81,10 +95,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 +398,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/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 387d226..d6e1a51 100644 --- a/coding_tools_mcp/transport_stdio.py +++ b/coding_tools_mcp/transport_stdio.py @@ -4,14 +4,30 @@ 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, + response_id, +) +from .telemetry import SessionTelemetry class StdioRuntime(Protocol): - protocol_version: str - initialized: bool + telemetry: SessionTelemetry - 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, 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]: ... @@ -20,11 +36,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: ... @@ -42,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: @@ -52,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/ci-and-tests.md b/docs/ci-and-tests.md index dc7e542..dd32ee0 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,9 +113,11 @@ 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. +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/competitive-analysis.md b/docs/competitive-analysis.md index 34fe134..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 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 | -| 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/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/limitations.md b/docs/limitations.md index 3948697..0170fb2 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -6,6 +6,28 @@ - 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 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. 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 + 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. +- 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 @@ -13,5 +35,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..e2a995e --- /dev/null +++ b/docs/migration-0.3.md @@ -0,0 +1,195 @@ +# 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 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 +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, 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 + +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 fabbf9b..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`. @@ -290,12 +292,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 @@ -304,6 +309,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/runtime-contract-v0.3.md b/docs/runtime-contract-v0.3.md new file mode 100644 index 0000000..eabb0fd --- /dev/null +++ b/docs/runtime-contract-v0.3.md @@ -0,0 +1,578 @@ +# 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??=`, 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 +`_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 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, 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` | + +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 + +- 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 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 + 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} + } +} +``` + +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 +["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`, +`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. + +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 +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, 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. + +### check_exec_environment + +Inputs: none. + +Annotations: `{"title":"Check exec environment","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. + +Returns lightweight policy, Landlock status, and the selected Windows command +shell. A `cmd.exe` fallback is repeated in `warnings`. + +### 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/telemetry.md b/docs/telemetry.md index ccc9fab..5873d50 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -33,19 +33,67 @@ 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. + +`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. + +## 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/docs/tools-and-schemas.md b/docs/tools-and-schemas.md index 2849e76..4217f91 100644 --- a/docs/tools-and-schemas.md +++ b/docs/tools-and-schemas.md @@ -1,19 +1,16 @@ # 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. ## 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 @@ -80,10 +77,14 @@ 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 -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 +108,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 @@ -129,6 +124,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/docs/troubleshooting.md b/docs/troubleshooting.md index fc41b38..f724d56 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 @@ -12,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/pyproject.toml b/pyproject.toml index e333f8c..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 = [ @@ -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/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 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/mcp_client.py b/tests/compliance/mcp_client.py index 5793a5c..b55ee10 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", @@ -105,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 @@ -259,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/runner.py b/tests/compliance/runner.py index b91d83e..ad8bac1 100644 --- a/tests/compliance/runner.py +++ b/tests/compliance/runner.py @@ -18,10 +18,11 @@ 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"], + "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/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_dual_era.py b/tests/compliance/test_dual_era.py new file mode 100644 index 0000000..d67ba46 --- /dev/null +++ b/tests/compliance/test_dual_era.py @@ -0,0 +1,584 @@ +"""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.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" +META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" +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.methods_sent: list[str] = [] + 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 + self.methods_sent.append(str(payload.get("method"))) + 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 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.""" + + 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` 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 + + 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], + } + + +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: + # 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) + 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() diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index e3a3bf3..f1886a3 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, @@ -31,6 +34,55 @@ 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" +META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" + + +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} + + +# 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() @@ -80,7 +132,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 +144,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_concurrent_http_clients_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,12 +157,12 @@ 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: + 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( @@ -136,6 +182,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) @@ -164,8 +231,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), @@ -291,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( @@ -353,44 +424,316 @@ 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. - 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", "")) + 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. + """ - def test_http_delete_terminates_only_the_selected_session(self) -> None: + 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_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) + + 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") - still_alive = self.client.rpc("ping", {}) - self.assertEqual(still_alive, {}) + 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_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) + 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. + 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_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, "prompts/list")) + 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. 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":{}}' + ) + 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)) + 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) @@ -401,9 +744,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) @@ -835,15 +1183,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): @@ -856,35 +1195,75 @@ 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) - 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 = { @@ -927,40 +1306,54 @@ 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_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 = 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,43 +1394,18 @@ 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() - - 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, - ) + self.stop_process(process) + + 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, @@ -1058,6 +1426,371 @@ def test_stdio_rejects_preinitialize_calls_and_accepts_cancel_notification(self) finally: self.stop_process(process) + 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() + 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") + + repeated = self.stdio_rpc( + process, + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": params}, + ) + 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) + + 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 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 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")) + 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(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) + + 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")) + result = pong.get("result", {}) + self.assert_modern_result(result) + self.assertNotIn("ttlMs", result) + self.assertNotIn("cacheScope", result) + + 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.assert_modern_result(omitted_client_info.get("result", {})) + 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: + 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) + + 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 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}") @@ -1067,6 +1800,26 @@ 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, workspace: Path | None = None) -> subprocess.Popen[str]: + root = workspace or self.workspace.root + return subprocess.Popen( + [ + sys.executable, + "-m", + "coding_tools_mcp", + "--workspace", + str(root), + "--stdio", + ], + cwd=str(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=(",", ":")) @@ -1151,7 +1904,9 @@ 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]]: self.assertIsNotNone(self.client.url) parsed = urllib.parse.urlparse(str(self.client.url)) @@ -1162,11 +1917,13 @@ 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) + for name, value in repeated_headers: + connection.putheader(name, value) connection.endheaders() if body: connection.send(body) @@ -1176,6 +1933,49 @@ 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], + *, + 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, diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index 59c01a3..5924dd4 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -13,11 +13,19 @@ 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.patching import AtomicPatchCommitter, FileBaseline, StagedFile +from coding_tools_mcp import telemetry as telemetry_module +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, @@ -36,10 +44,24 @@ 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 +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. @@ -350,8 +372,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) @@ -485,6 +511,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) @@ -1012,7 +1067,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" @@ -1022,16 +1077,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( @@ -1343,13 +1397,96 @@ 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") self.assertEqual(snapshot.get("stdout_omitted_bytes"), 2) self.assertIs(snapshot.get("truncated"), True) - def test_default_cwd_and_git_convenience_tools(self) -> None: + 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_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: + 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) + + # 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, + { + "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") with TemporaryDirectory() as tmp: @@ -1368,9 +1505,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}) @@ -1381,12 +1516,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: @@ -1491,6 +1626,233 @@ 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 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 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)) 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_telemetry.py b/tests/test_telemetry.py index 39cc030..efc5bf3 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(): @@ -93,11 +131,33 @@ 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() + 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: @@ -126,7 +186,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() @@ -142,61 +202,205 @@ 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["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) + 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: 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, []) + 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: + # 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() + + 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() + 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 +417,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 +432,71 @@ 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", + ], + ) + + 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: 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) diff --git a/tests/test_windows_pwsh.py b/tests/test_windows_pwsh.py new file mode 100644 index 0000000..dae98f8 --- /dev/null +++ b/tests/test_windows_pwsh.py @@ -0,0 +1,664 @@ +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 import server +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] = {} + + 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_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" + + 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_invalid_explicit_pwsh_pin_does_not_fall_back(self) -> None: + with ( + 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_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" + 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") + + 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: + 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", ""))) + + @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: + 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", + # 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") + try: + for command in commands: + with self.subTest(command=command): + with self.assertRaises(ToolFailure) as raised: + runtime._check_command_policy(command, {}, windows_shell=PWSH_SHELL) + 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 .", + # 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") + try: + for command in commands: + with self.subTest(command=command): + with self.assertRaises(ToolFailure) as raised: + runtime._check_command_policy(command, {}, windows_shell=PWSH_SHELL) + self.assertEqual( + raised.exception.details.get("permission"), + "destructive_command", + ) + finally: + 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: + try: + runtime._check_command_policy(command, {}, windows_shell=PWSH_SHELL) + 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", + # 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) + 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", + # 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") + + 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')")) + + +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: + 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__": + unittest.main() 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