diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index d05df30b..92e287e5 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -243,6 +243,22 @@ jobs: --transcript "$TRANSCRIPTS/python.jsonl" \ -- node "$CLI" stdio + - name: Smoke cycle (python, protocol 2026-07-28) + # Every other cycle opens with `initialize`: the 2025 era, which stays + # covered. From 0.27.0 the bundle serves 2026-07-28 too (issue #1025): + # the same cycle, opened with server/discover, each request carrying + # its own envelope. The driver decides from the installed package's + # own version — skipped with a notice before 0.27.0, strict from it, + # prereleases included (scripts/canary-era-gate.mjs). + run: | + ARTIFACT_VERSION="$(node -p "require(process.argv[1]).version" "$(npm root -g)/@debugmcp/mcp-debugger/package.json")" + echo "ARTIFACT_VERSION=$ARTIFACT_VERSION" >> "$GITHUB_ENV" + node scripts/canary-smoke.mjs --lang python --era 2026-07-28 --artifact-version "$ARTIFACT_VERSION" \ + --program "$GITHUB_WORKSPACE/examples/python/simple_test.py" --line 11 \ + --expect-line --expect-var a --expect-var b \ + --transcript "$TRANSCRIPTS/python-2026.jsonl" \ + -- node "$CLI" stdio + - name: Smoke cycle (rust — CodeLLDB engine proof) env: LEG: ${{ matrix.leg }} @@ -424,6 +440,19 @@ jobs: --transcript "$TRANSCRIPTS/python.jsonl" \ -- docker run -i --rm -v "$GITHUB_WORKSPACE:/workspace" "$IMAGE" stdio + - name: Smoke cycle (python, protocol 2026-07-28) + # Read the version from the pulled image's synchronized shared package. + # npm's latest tag can move before Docker's during a release; querying + # npm here can apply the new era's requirements to an older image. + run: | + ARTIFACT_VERSION="$(docker run --rm --entrypoint node "$IMAGE" -p "require('/app/node_modules/@debugmcp/shared/package.json').version")" + echo "ARTIFACT_VERSION=$ARTIFACT_VERSION" >> "$GITHUB_ENV" + node scripts/canary-smoke.mjs --lang python --era 2026-07-28 --artifact-version "$ARTIFACT_VERSION" \ + --program examples/python/simple_test.py --line 11 \ + --expect-line --expect-var a --expect-var b \ + --transcript "$TRANSCRIPTS/python-2026.jsonl" \ + -- docker run -i --rm -v "$GITHUB_WORKSPACE:/workspace" "$IMAGE" stdio + - name: Smoke cycle (rust — CodeLLDB engine proof) # The image ships no compiler; build the fixture in a rust container # against the same /workspace mount so the DWARF paths match what the diff --git a/CLAUDE.md b/CLAUDE.md index 75efa1ad..381177e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -528,6 +528,7 @@ The dev proxy (`tools/dev-proxy/dev-proxy.mjs`) is a lightweight MCP proxy that - `DEV_PROXY_BUILD_TIMEOUT_MS` — Build timeout in milliseconds (default: 120000) - `DEV_PROXY_ROOT` — Project root (default: auto-detected) - `DEV_PROXY_BACKEND_TRANSPORT` — `http` (default) or `stdio`; any other value refuses to start, naming the variable +- `DEV_PROXY_BACKEND_ERA` — `auto` (default: negotiate the era with the backend) or `legacy` (speak 2025-11-25 to it — the way to exercise the 2025 era through the proxy, which otherwise lands on 2026-07-28 with every current backend); any other value refuses to start, naming the variable - `DEV_PROXY_BACKEND_CMD` — Custom backend command override (e.g. `docker run ...`) - `DEV_PROXY_DISCOVERY_WAIT_MS` — How long a request waits for a backend start or restart to settle (default: 15000; keep it under the client's MCP startup timeout, `0` disables the wait) diff --git a/changelog.d/1025-dev-proxy-era.added.md b/changelog.d/1025-dev-proxy-era.added.md new file mode 100644 index 00000000..dcf38d46 --- /dev/null +++ b/changelog.d/1025-dev-proxy-era.added.md @@ -0,0 +1 @@ +**`DEV_PROXY_BACKEND_ERA` chooses the protocol era the dev proxy speaks to its backend** — the proxy negotiates by default (`auto`), so against a current backend it always lands on 2026-07-28 and nothing driven through it exercises the 2025 era that most clients still speak. `DEV_PROXY_BACKEND_ERA=legacy` opens with `initialize` instead (2025-11-25); `dev_server_status` shows the era in force, and any other value refuses to start, naming the variable (#1025) diff --git a/changelog.d/1025-idle-policy.added.md b/changelog.d/1025-idle-policy.added.md index 3cf88373..3a3a4a1f 100644 --- a/changelog.d/1025-idle-policy.added.md +++ b/changelog.d/1025-idle-policy.added.md @@ -1 +1 @@ -**An opt-in per-debug-session idle policy; nothing expires by default.** A debug session ends by its own close, its program ending, or the server exiting, on every transport — no timer ends one unless the operator asks. With `MCP_DEBUG_SESSION_IDLE_MS` set, a debug-session tree that no tool call has named for that long is closed through the normal close cascade — a launch root closes its subtree and ends its program, an attach root releases its targets — unless a client is attending the process (an open SSE stream), in which case nothing expires; a tool call naming any session in a tree refreshes the whole tree, an in-flight `wait_for_stop` holds it, and children never expire on a clock of their own. The sweep runs every `MCP_DEBUG_SESSION_IDLE_SWEEP_MS` (default 60 s). The agent can see it: `list_debug_sessions` rows carry `idleMs` (and `expiresInMs` when a policy is on), a tool call naming a session the policy closed answers what happened — when, under which window, and whether its program was ended or released (`closedBy: "idle-policy"`) — instead of "Session not found", and the server instructions mention the policy when it is on. The HTTP reapers (`MCP_HTTP_STALE_SESSION_MS`, `MCP_HTTP_STREAM_LOST_SESSION_MS`) still reap abandoned MCP sessions, but reaping one no longer closes any debug session: **a crashed client's debug sessions stay until someone closes them, the idle window (if set) runs out, or the server exits.** Set the variable on a shared or long-lived `http` server, and wherever an attached process must not stay paused (the Kubernetes recipe sets 5 minutes). Step 2 of #1025. +**An opt-in per-debug-session idle policy; nothing expires by default.** A debug session ends by its own close, its program ending, or the server exiting, on every transport — no timer ends one unless the operator asks. With `MCP_DEBUG_SESSION_IDLE_MS` set, a debug-session tree that no tool call has named for that long is closed through the normal close cascade — a launch root closes its subtree and ends its program, an attach root releases its targets — unless a client is attending the process (an open SSE stream), in which case nothing expires; a tool call naming any session in a tree refreshes the whole tree, an in-flight `wait_for_stop` holds it, and children never expire on a clock of their own. The sweep runs every `MCP_DEBUG_SESSION_IDLE_SWEEP_MS` (default 60 s). The agent can see it: `list_debug_sessions` rows carry `idleMs` (and `expiresInMs` when a policy is on), a tool call naming a session the policy closed answers what happened — when, under which window, and whether its program was ended, released, or was not running by then (`closedBy: "idle-policy"`) — instead of "Session not found", and the server instructions mention the policy when it is on. The HTTP reapers (`MCP_HTTP_STALE_SESSION_MS`, `MCP_HTTP_STREAM_LOST_SESSION_MS`) still reap abandoned MCP sessions, but reaping one no longer closes any debug session: **a crashed client's debug sessions stay until someone closes them, the idle window (if set) runs out, or the server exits.** Set the variable on a shared or long-lived `http` server, and wherever an attached process must not stay paused (the Kubernetes recipe sets 5 minutes). Step 2 of #1025. diff --git a/changelog.d/1025-restart-description.fixed.md b/changelog.d/1025-restart-description.fixed.md new file mode 100644 index 00000000..cfc91472 --- /dev/null +++ b/changelog.d/1025-restart-description.fixed.md @@ -0,0 +1 @@ +**The `restart_debugging` description no longer says attach sessions can never restart** — a Flutter session hot-restarts in place, an attached one included (same process, output buffer kept, `hotRestart: true` in the answer), but the tool description every client reads still said "Not available for attach sessions". It now states the Flutter path and that attach sessions of the other adapters are the ones refused (#1025) diff --git a/changelog.d/1025-setting-warnings.security.md b/changelog.d/1025-setting-warnings.security.md new file mode 100644 index 00000000..8ad47808 --- /dev/null +++ b/changelog.d/1025-setting-warnings.security.md @@ -0,0 +1 @@ +**A rejected `MCP_HTTP_*` setting is named in the log; its value is not** — an invalid `MCP_HTTP_STALE_SESSION_MS`, `MCP_HTTP_STREAM_LOST_SESSION_MS` or `MCP_HTTP_STALE_SWEEP_INTERVAL_MS` used to be quoted in the warning that rejects it, so a secret pasted into the wrong variable, or a value with a line break in it, was copied into the server log. The warning now names the variable and the default in force, as the debug-session idle settings already do (#1025) diff --git a/docs/architecture/session-tree.md b/docs/architecture/session-tree.md index f97c83d1..5352c01f 100644 --- a/docs/architecture/session-tree.md +++ b/docs/architecture/session-tree.md @@ -22,7 +22,8 @@ default, the attach measurement, the lifecycle suites) in program's own is a child session, the throwaway release, `outputOn`, a child's close as a release, the fork/grandchild suite and the parent-exit measurement below) merged in [#1094](https://github.com/debugmcp/mcp-debugger/pull/1094), PR 2 (the launch and verified-local -attach defaults on, the attach, cascade and cluster suites) follows it. +attach defaults on, the attach, cascade and cluster suites) in +[#1096](https://github.com/debugmcp/mcp-debugger/pull/1096). Scope: debuggee children reported by the adapter (debugpy subprocesses, js-debug forks and, later, browser targets). The tree is additive to the shipped tool surface: no new tool, no changed parameter, new fields only. @@ -292,8 +293,8 @@ fold children into the one session, or ignore them. Surveyed 2026-11-03: | python | debugpy 1.8.20 | `startDebugging` when advertised, else a `debugpyAttach` event with the same body; child parked until a client attaches | reject the request: the adapter unblocks the child itself, 100 ms | own lines on the child connection with `redirectOutput`, plus the parent's pipe copy | none (a parked child cannot be attached by the agent: no port is reported to it) | [spike notes](../python/spike-notes-subprocess.md), measured | | javascript | js-debug 1.140.0 | one `startDebugging { __pendingTargetId }` per process, incl. the program itself; child parked in `waitForDebugger` | a throwaway attach-then-detach ([#501](https://github.com/debugmcp/mcp-debugger/issues/501)); rejecting parks the child forever | tree-wide on the root connection, no attribution | `attach_to_process` by pid or inspector port, which is a new root, not a child | [spike notes](../javascript/spike-notes-forks.md), measured | | javascript (browser) | js-debug `pwa-chrome` | the same `startDebugging`, one per page, frame and worker | as above | unmeasured | none | [#989](https://github.com/debugmcp/mcp-debugger/issues/989), unmeasured | -| ruby | debug gem 1.11.0 (rdbg) | **out of band**: with `fork_mode: both` (the default) the forked child runs `SESSION.activate on_fork: true` and opens its own debug server — the same TCP port with `RUBY_DEBUG_PORT_RANGE` unset (ten retries, then it raises), the next free port in the range otherwise — announced only by a `Debugger can attach via TCP/IP (host:port)` line on the child's stderr; nothing on the parent's DAP connection | not needed: the child is not parked | the child's stderr reaches the launch session through the inherited pipe | `attach_to_process` by port against the child's own server (the adapter already connects to `rdbg --open` sockets directly), a new root | debug gem source read 2026-11-03: `lib/debug/session.rb` `__fork_setup_for_debugger`, `lib/debug/server.rb` `UI_TcpServer#accept`; Ruby on Windows has no `fork`, so a measurement needs Linux | -| rust, cpp, cobol | CodeLLDB / LLDB 22.1.8 | **follow**: `target.process.follow-fork-mode` is `parent` or `child` — the debugger keeps one process and releases the other; `stop-on-exec` is `true`; Linux and FreeBSD only; never a report, never a second session | not applicable | not applicable | cpp and cobol: `attach_to_process` by pid, a new root; rust: no attach mode | setting values read from the vendored `lldb.exe` 2026-11-03 (`follow-fork-mode = parent`); [LLVM D100503](https://reviews.llvm.org/D100503) | +| ruby | debug gem 1.11.0 (rdbg) | **out of band**: with `fork_mode: both` (the default) the forked child runs `SESSION.activate on_fork: true` and opens its own debug server — the same TCP port with `RUBY_DEBUG_PORT_RANGE` unset (ten retries, then it raises), the next free port in the range otherwise — announced only by a `Debugger can attach via TCP/IP (host:port)` line on the child's stderr; nothing on the parent's DAP connection. A second reading of `server.rb` (2026-11-11) disagrees with that last clause: the TCP server's `accept` calls `super` first, which serves the inherited, already-connected socket (`@sock_for_fork`, `already_connected: true`) before it opens a listener of its own. Unmeasured either way; the [#1085](https://github.com/debugmcp/mcp-debugger/issues/1085) spike settles it | not needed: the child is not parked | the child's stderr reaches the launch session through the inherited pipe | `attach_to_process` by port against the child's own server (the adapter already connects to `rdbg --open` sockets directly), a new root | debug gem source read 2026-11-03: `lib/debug/session.rb` `__fork_setup_for_debugger`, `lib/debug/server.rb` `UI_TcpServer#accept`; Ruby on Windows has no `fork`, so a measurement needs Linux | +| rust, cpp, cobol | CodeLLDB / LLDB 22.1.8 | **follow**: `target.process.follow-fork-mode` is `parent` or `child` — the debugger keeps one process and releases the other; `stop-on-exec` is `true`; Linux and FreeBSD only; never an automatic report. Since CodeLLDB 1.12 the scripting API has `codelldb.start_debugging(request, configuration)` (the vendored `adapter/scripts/codelldb/api.py`), which makes the adapter send a `startDebugging` reverse request: a script-driven second session, unmeasured here | not applicable | not applicable | cpp and cobol: `attach_to_process` by pid, a new root; rust: no attach mode | setting values read from the vendored `lldb.exe` 2026-11-03 (`follow-fork-mode = parent`); [LLVM D100503](https://reviews.llvm.org/D100503) | | go | Delve (CI pins 1.24.2 as the oldest supported, 1.27.2 current) | **follow and fold**: `target follow-exec [-on [regex]]` attaches to child processes the target execs, `target list` / `target switch` move between them inside the one session; DAP `launch` takes `followExec` / `followExecRegex` since 1.26.0 (attach: "not supported yet"); one `process` event, one goroutine list; Linux first (1.21.0), Windows follow since 1.21.2 | not applicable | not applicable | no attach mode | Delve CHANGELOG 1.21.0 / 1.21.2 / 1.26.0 / 1.27.1, `Documentation/cli/README.md` (`target`), `service/dap/server.go` (`followExec`), read 2026-11-03; how a second target is presented over DAP is unmeasured | | java | the JDI bridge (`JdiDapServer.java`) | none: a child JVM is a new process that needs its own JDWP agent; JDI has no process following | not applicable | not applicable | `attach_to_process` by JDWP port, a new root | our own bridge; no `startDebugging`, no child handling in it | | dotnet | netcoredbg | none documented | not applicable | not applicable | `attach_to_process` by pid, a new root | netcoredbg README (attach by pid only; nothing on child processes), read 2026-11-03 | diff --git a/docs/architecture/testing-architecture.md b/docs/architecture/testing-architecture.md index a1747f4e..cccf9f63 100644 --- a/docs/architecture/testing-architecture.md +++ b/docs/architecture/testing-architecture.md @@ -276,6 +276,8 @@ Two tiers, split along the seam between this server's own code and the SDK, and Everything after the hand-off is the SDK's conformance, and `scripts/mcp-conformance.mjs` (`pnpm test:conformance` for the 2025-11-25 lane, `pnpm test:conformance:2026` for the 2026-07-28 one) runs the protocol org's `@modelcontextprotocol/conformance` suite against a local `http` server in two lanes on Linux. The legacy lane runs the pinned 0.1.16 CLI for the scenarios that apply to a server without its reference fixtures — initialize, ping, the three listings, multiple SSE streams, DNS-rebinding protection. The 2026-07-28 lane (`--era 2026-07-28`, CI job `mcp-conformance-2026`) runs the pinned 0.2 alpha's `--requirements 2026-07-28` set — everything the revision requires of a server — and lets the suite judge it: the suite reads the baseline (`scripts/conformance/expected-failures-2026-07-28.yaml`) with its own `--expected-failures`, fails on a failure or warning that is not listed and on a listed entry that passes, and its exit code is the verdict; the script adds no judging of its own. The baseline lists the checks that cannot be tested without the suite's reference fixtures (one in each `tools-call-*`, `prompts-get-*` and `resources-read-*` scenario, four in `server-stateless`) or without an optional feature this server does not declare (`completion-complete`, the `input-required-result-*` scenarios), each a single `scenario:check-id` with its reason — never a whole scenario, so the other checks of those scenarios, the schema validation of every message this server sends among them, stay enforced; by the suite's policy a missing prerequisite is a failure, not a skip, and a baseline is its named mechanism for that. The suite has no session-lifecycle scenario, which is why the first tier exists; both CLIs are pinned. One caveat for local runs: Node 23 to 24.19 and 26.0 to 26.6 abort inside `process.exit` on Windows (nodejs/node#56645, fixed in 24.20.0 and 26.7.0) after the suite has printed everything, so there the legacy lane reads the printed summaries and the 2026 lane reads the suite's closing line when that abort is in the output; CI runs Linux and takes the exit code. +The packaged artifacts are held to both eras as well (issue #1025). The Docker and npx Python smokes (`tests/e2e/docker/docker-smoke-python.test.ts`, `tests/e2e/npx/npx-smoke-python.test.ts`) run their cycle with the SDK client's default, which opens with `initialize`, and once more pinned to 2026-07-28 (`protocol: 'modern'` on the smoke helpers; a transport subclass, so the probe runs on the connection rather than on a sibling `docker run`). The published-artifact canary does the same against what users install: `scripts/canary-smoke.mjs` is a raw 2025-era client, and `--era 2026-07-28` runs the cycle in the modern era for artifacts from 0.27.0 on (`scripts/canary-era-gate.mjs`). + ## Coverage Strategy **Provider:** Istanbul. **Reporters:** text, json, html, json-summary (output to `./coverage/`). `reportOnFailure: true` ensures partial coverage is captured even when tests fail. Coverage is configured at the **root** of `vitest.config.ts`, not per project — a per-project `coverage` block is silently ignored. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index b9741969..9129b6a4 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -64,7 +64,7 @@ A release is two cut PRs — the beta rehearsal (`x.y.z-beta.1`), then the final - [ ] GitHub Release body has the correct changelog content - [ ] Verify provenance: `gh attestation verify debugmcp-mcp-debugger-x.y.z.tgz --repo debugmcp/mcp-debugger` (download the asset first) - [ ] Verify npm: each published package shows the new version with `latest` dist-tag and a provenance badge on npmjs.com; `npm audit signatures` passes in a scratch project that installs them -- [ ] **Run the canary as the release gate**: `gh workflow run canary.yml -f version=x.y.z` and confirm every leg is green (Actions → "Canary (published artifacts)"). It installs the published npm package (default and `--omit=optional` + `CODELLDB_PATH` legs), runs npx, pulls the Docker image, and drives a breakpoint→variables→continue cycle in mock/python/rust/cobol/dart on x64/arm64 Linux, arm64 macOS, and Windows — superseding the manual npx/docker spot checks below. Every leg is strict: a published artifact whose `list_supported_languages` lacks `cobol` or `dart` fails its leg (#1053). Only for a canary of a version older than 0.25.0 (COBOL) or 0.26.0 (Dart) add `-f allow_missing_languages=true`, which lets those legs pass with a notice +- [ ] **Run the canary as the release gate**: `gh workflow run canary.yml -f version=x.y.z` and confirm every leg is green (Actions → "Canary (published artifacts)"). It installs the published npm package (default and `--omit=optional` + `CODELLDB_PATH` legs), runs npx, pulls the Docker image, and drives a breakpoint→variables→continue cycle in mock/python/rust/cobol/dart on x64/arm64 Linux, arm64 macOS, and Windows — superseding the manual npx/docker spot checks below. Every leg is strict: a published artifact whose `list_supported_languages` lacks `cobol` or `dart` fails its leg (#1053). Only for a canary of a version older than 0.25.0 (COBOL) or 0.26.0 (Dart) add `-f allow_missing_languages=true`, which lets those legs pass with a notice. The Python leg of each channel also runs once in protocol 2026-07-28 (opened with `server/discover`), beside the 2025-era cycles every leg runs: skipped with a notice for an artifact older than 0.27.0, strict from 0.27.0 on, prereleases included — the driver decides from the artifact's own version (`scripts/canary-era-gate.mjs`, #1025) - [ ] Fallback manual checks (when the canary can't run): `npx @debugmcp/mcp-debugger@x.y.z stdio` works (if the npx cache misbehaves, `npm install --prefix ` is the reliable smoke path); `docker pull debugmcp/mcp-debugger:x.y.z` works - [ ] Verify: PyPI has `debug-mcp-server-launcher==x.y.z` - [ ] Verify the official MCP Registry lists the version: `curl -s "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.debugmcp/mcp-debugger"` shows `x.y.z`. The listing comes from `server.json` (moved by `scripts/sync-versions.cjs`, checked by `scripts/check-mcp-registry-metadata.mjs` in the dry-run); the registry verifies the npm package's `mcpName` and the Docker image's `io.modelcontextprotocol.server.name` label at exactly that version, and the job logs in with GitHub Actions OIDC (no secret) (#835) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 5429fbb5..7e8637c2 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -1824,7 +1824,7 @@ Session-lifecycle failures (unknown/terminated session, proxy not running) are a ``` ### Common Error Scenarios -1. **Session not found**: Occurs when a session terminates unexpectedly or was closed. A session the idle policy closed (`MCP_DEBUG_SESSION_IDLE_MS`, see `create_debug_session`) says so instead: `{ "success": false, "error": "Session … was closed by the idle policy at … Its program was ended. Create a new session to continue.", "closedBy": "idle-policy" }`. An id created by another MCP client of the same process is found like any other; one outside the caller's scope (a future authenticated `http` mode; today every caller shares one scope) does not exist for that caller — every tool answers it exactly as it answers an id nobody holds (issue #1025) +1. **Session not found**: Occurs when a session terminates unexpectedly or was closed. A session the idle policy closed (`MCP_DEBUG_SESSION_IDLE_MS`, see `create_debug_session`) says so instead: `{ "success": false, "error": "Session … was closed by the idle policy at … Its program was ended. Create a new session to continue.", "closedBy": "idle-policy" }`. The sentence about the program is one of three: "Its program was ended." (a launched program the policy ended), "The process it was attached to was released and keeps running.", or "It had no running program by then." (the session never launched, or its program was already over). An id created by another MCP client of the same process is found like any other; one outside the caller's scope (a future authenticated `http` mode; today every caller shares one scope) does not exist for that caller — every tool answers it exactly as it answers an id nobody holds (issue #1025) 2. **Invalid language**: Language must be one of the supported languages (discovered dynamically from installed adapters) 3. **File not found**: When setting breakpoints in non-existent files 4. **Invalid scope**: When passing wrong variablesReference to get_variables diff --git a/scripts/canary-era-gate.d.mts b/scripts/canary-era-gate.d.mts new file mode 100644 index 00000000..4ae0f8cd --- /dev/null +++ b/scripts/canary-era-gate.d.mts @@ -0,0 +1,14 @@ +/** + * Types for `canary-era-gate.mjs`: which published artifacts owe the canary a + * 2026-07-28 leg (issue #1025). + */ + +/** The first release whose artifacts serve MCP protocol 2026-07-28. */ +export const FIRST_MODERN_ERA_RELEASE: string; + +/** + * Whether an artifact of this version serves the 2026-07-28 era. A prerelease + * is judged as its release; a version that is not `major.minor.patch` (with an + * optional prerelease suffix and an optional leading `v`) throws. + */ +export function servesModernEra(version: string): boolean; diff --git a/scripts/canary-era-gate.mjs b/scripts/canary-era-gate.mjs new file mode 100644 index 00000000..e39f1213 --- /dev/null +++ b/scripts/canary-era-gate.mjs @@ -0,0 +1,46 @@ +/** + * Which published artifacts owe the canary a 2026-07-28 leg (issue #1025). + * + * Releases up to 0.26.x speak MCP protocol 2025-11-25 only; 0.27.0 is the + * first whose npm bundle and Docker image answer `server/discover`. The canary + * runs against whatever version it is pointed at, so its 2026 legs ask here: + * before 0.27.0 the leg is skipped with a notice, from 0.27.0 it is strict. + * + * A prerelease is judged as its release — `0.27.0-beta.1` is a 0.27.0 + * artifact — so the beta rehearsal runs the leg the final is waiting on. A + * version that cannot be read throws: guessing "old" would silently turn a + * strict leg into a skipped one. + * + * Dependency-free on purpose, like canary-smoke.mjs, which imports it. + */ + +/** The first release whose artifacts serve MCP protocol 2026-07-28. */ +export const FIRST_MODERN_ERA_RELEASE = '0.27.0'; + +const RELEASE_VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/; + +/** + * @param {string} version + * @returns {[number, number, number]} major, minor, patch — the prerelease suffix dropped + */ +function coreVersion(version) { + const match = RELEASE_VERSION.exec(version); + if (!match) { + throw new Error(`'${version}' is not a release version (expected major.minor.patch, optionally with a prerelease suffix)`); + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +/** + * Whether an artifact of this version serves the 2026-07-28 era. + * + * @param {string} version an artifact's own version, e.g. `0.27.0` or `0.27.0-beta.1` + * @returns {boolean} + */ +export function servesModernEra(version) { + const [major, minor, patch] = coreVersion(version); + const [firstMajor, firstMinor, firstPatch] = coreVersion(FIRST_MODERN_ERA_RELEASE); + if (major !== firstMajor) return major > firstMajor; + if (minor !== firstMinor) return minor > firstMinor; + return patch >= firstPatch; +} diff --git a/scripts/canary-smoke.mjs b/scripts/canary-smoke.mjs index 52a6c5a1..64e96551 100644 --- a/scripts/canary-smoke.mjs +++ b/scripts/canary-smoke.mjs @@ -22,6 +22,15 @@ * * Exit codes: 0 pass, 1 cycle/assertion failure, 2 usage or spawn error. * + * Protocol era (issue #1025): by default the cycle opens with `initialize`, + * the 2025 era every release speaks. `--era 2026-07-28` runs the same cycle + * in the modern era instead: it opens with `server/discover` and every request + * carries the per-request envelope in `params._meta`, with no session behind + * it. Releases before 0.27.0 cannot speak it, so pass the artifact's own + * version with `--artifact-version`: an older artifact is skipped with a + * notice, 0.27.0 and later (prereleases included) are held to it. Without + * `--artifact-version` the modern cycle is strict. + * * The server command comes after `--` (so its own flags pass through), e.g.: * node scripts/canary-smoke.mjs \ * --lang python --program examples/python/simple_test.py --line 11 \ @@ -37,7 +46,15 @@ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { parseArgs } from 'util'; - +import { FIRST_MODERN_ERA_RELEASE, servesModernEra } from './canary-era-gate.mjs'; + +const MODERN_ERA = '2026-07-28'; +/** What a 2026-07-28 request carries in `params._meta`: there is no handshake to have said it once. */ +const MODERN_ENVELOPE = { + 'io.modelcontextprotocol/protocolVersion': MODERN_ERA, + 'io.modelcontextprotocol/clientInfo': { name: 'canary-smoke', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; const POLL_INTERVAL_MS = 250; const REQUEST_TIMEOUT_MS = 30000; const KILL_GRACE_MS = 5000; @@ -54,6 +71,7 @@ function usage(message) { ' [--program ] [--break-file ] [--line ]\n' + ' [--adapter-config ] [--expect-var ]... [--expect-line]\n' + ' [--list-only] [--skip-if-missing] [--timeout ] [--transcript ]\n' + + ` [--era legacy|${MODERN_ERA}] [--artifact-version ]\n` + ' -- [server args...]' ); process.exit(2); @@ -75,7 +93,9 @@ try { 'list-only': { type: 'boolean' }, 'skip-if-missing': { type: 'boolean' }, timeout: { type: 'string' }, - transcript: { type: 'string' } + transcript: { type: 'string' }, + era: { type: 'string' }, + 'artifact-version': { type: 'string' } } }); options = parsed.values; @@ -86,6 +106,20 @@ try { if (!serverCommand || serverCommand.length === 0) usage('server command after -- is required'); if (!options.lang) usage('--lang is required'); +const era = options.era ?? 'legacy'; +if (era !== 'legacy' && era !== MODERN_ERA) usage(`--era must be 'legacy' or '${MODERN_ERA}'`); +const modernEra = era === MODERN_ERA; +if (options['artifact-version'] !== undefined && !modernEra) usage(`--artifact-version only applies with --era ${MODERN_ERA}`); +// Decided before anything is spawned. An unreadable version is a usage error, +// never a silent skip. +let eraOwed = true; +if (modernEra && options['artifact-version'] !== undefined) { + try { + eraOwed = servesModernEra(options['artifact-version']); + } catch (err) { + usage(`--artifact-version: ${err.message}`); + } +} if (!options['list-only']) { if (!options.program) usage('--program is required (unless --list-only)'); if (!options.line) usage('--line is required (unless --list-only)'); @@ -122,7 +156,8 @@ function transcribe(dir, data) { /* ---------- raw stdio MCP client ---------- */ class McpStdioClient { - constructor(command, args) { + constructor(command, args, { modern = false } = {}) { + this.modern = modern; this.nextId = 1; this.pending = new Map(); // id -> { resolve, reject, timer } this.exited = false; @@ -199,13 +234,15 @@ class McpStdioClient { request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) { if (this.exited || this.disposed) return Promise.reject(new Error('server already exited')); const id = this.nextId++; + // In the modern era every request states its protocol version and client itself. + const sent = this.modern ? { ...params, _meta: { ...params?._meta, ...MODERN_ENVELOPE } } : params; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`request '${method}' timed out after ${timeoutMs}ms`)); }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); - this.send({ jsonrpc: '2.0', id, method, params }); + this.send({ jsonrpc: '2.0', id, method, params: sent }); }); } @@ -213,16 +250,29 @@ class McpStdioClient { this.send({ jsonrpc: '2.0', method, ...(params ? { params } : {}) }); } - async initialize() { + /** + * Open the connection in the client's era and say how. Legacy: the + * `initialize` handshake. Modern: one `server/discover`, which must name + * 2026-07-28 — the opening message is also what pins a stdio server's era. + */ + async open() { const spawnErr = await this.spawnError; if (spawnErr) throw Object.assign(new Error(`failed to spawn server: ${spawnErr.message}`), { usageError: true }); - const result = await this.request('initialize', { + if (this.modern) { + const discovered = await this.request('server/discover', {}); + const supported = discovered?.supportedVersions ?? []; + if (!supported.includes(MODERN_ERA)) { + throw new Error(`server/discover does not name ${MODERN_ERA}: supportedVersions=${JSON.stringify(supported)}`); + } + return `server/discover ok: ${MODERN_ERA} among ${JSON.stringify(supported)}`; + } + await this.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'canary-smoke', version: '1.0.0' } }); this.notify('notifications/initialized'); - return result; + return 'initialize ok'; } /** @@ -398,7 +448,14 @@ async function runCycle(client) { /* ---------- main ---------- */ async function main() { - const client = new McpStdioClient(serverCommand[0], serverCommand.slice(1)); + if (!eraOwed) { + // An era gate, like --skip-if-missing: an artifact from before the era shipped is not a failure. + console.log(`::notice::${options['artifact-version']} predates ${FIRST_MODERN_ERA_RELEASE}, the first release that serves protocol ${MODERN_ERA} — ${MODERN_ERA} cycle skipped`); + step(`era ${MODERN_ERA}`, `not owed by ${options['artifact-version']}, cycle skipped`); + if (transcriptStream) await new Promise((resolve) => transcriptStream.end(resolve)); + return; + } + const client = new McpStdioClient(serverCommand[0], serverCommand.slice(1), { modern: modernEra }); const watchdog = setTimeout(() => { console.error(`[canary] FAIL: overall timeout of ${overallTimeoutMs}ms exceeded`); printFailureTail(); @@ -406,10 +463,9 @@ async function main() { }, overallTimeoutMs); try { - await client.initialize(); - step('initialize ok'); + step(await client.open()); await runCycle(client); - console.log(`[canary] PASS (${options.lang}, ${Date.now() - startedAt}ms)`); + console.log(`[canary] PASS (${options.lang}, era ${era}, ${Date.now() - startedAt}ms)`); } catch (err) { console.error(`[canary] FAIL: ${err.message}`); printFailureTail(); diff --git a/src/cli/http-command.ts b/src/cli/http-command.ts index e5e26212..a32c5c09 100644 --- a/src/cli/http-command.ts +++ b/src/cli/http-command.ts @@ -127,7 +127,8 @@ function parseNonNegativeMs( } const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed < 0) { - logger.warn(`Ignoring invalid ${name} value "${raw}"; using default ${fallback}ms.`); + // Name the rejected setting without copying untrusted environment contents into logs. + logger.warn(`Ignoring invalid ${name}; using default ${fallback}ms.`); return fallback; } return parsed; @@ -180,7 +181,7 @@ function parseSweepIntervalMs(raw: string | undefined, logger: WinstonLoggerType } const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { - logger.warn(`Ignoring invalid MCP_HTTP_STALE_SWEEP_INTERVAL_MS value "${raw}"; using default ${DEFAULT_STALE_SWEEP_INTERVAL_MS}ms.`); + logger.warn(`Ignoring invalid MCP_HTTP_STALE_SWEEP_INTERVAL_MS; using default ${DEFAULT_STALE_SWEEP_INTERVAL_MS}ms.`); return DEFAULT_STALE_SWEEP_INTERVAL_MS; } return parsed; diff --git a/src/server/tool-schemas.ts b/src/server/tool-schemas.ts index a5d2a4b1..4ab7fc5f 100644 --- a/src/server/tool-schemas.ts +++ b/src/server/tool-schemas.ts @@ -186,7 +186,7 @@ export function buildToolDefinitions(options: BuildToolDefinitionsOptions): Tool required: ['sessionId', 'scriptPath'] } }, - { name: 'restart_debugging', description: 'Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including "running" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, + { name: 'restart_debugging', description: 'Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including "running" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'attach_to_process', description: 'Attach to a running process for debugging. After the handshake the target is verified by polling for threads; if none are reported within verifyTimeout (~20s default) the attach fails and the proxy is torn down. When a requested post-attach pause is accepted but no stopped event arrives within its bounded wait, returns state "running" with pending:true; the late event alone changes the session to "paused" — call wait_for_stop to block until it does', inputSchema: { type: 'object', properties: { diff --git a/src/session/idle-policy.ts b/src/session/idle-policy.ts index f7dbfb3c..2a6b8624 100644 --- a/src/session/idle-policy.ts +++ b/src/session/idle-policy.ts @@ -71,10 +71,20 @@ export interface IdleExpiryRecord { /** How long the tree had gone without a tool call when it was closed. */ readonly idleMs: number; readonly windowMs: number; - /** What the close did to the program: a launched one is ended, an attached one released. */ - readonly outcome: 'ended' | 'released'; + /** + * What the close did to the program: a launched one is `ended`, an attached + * one `released`. `closed` when there was none to act on — the session never + * launched, or its program was already over when the policy came for it. + */ + readonly outcome: 'ended' | 'released' | 'closed'; } +const OUTCOME_SENTENCE: Record = { + ended: 'Its program was ended.', + released: 'The process it was attached to was released and keeps running.', + closed: 'It had no running program by then.' +}; + /** How many expiry records the core keeps; the oldest go first. */ export const IDLE_EXPIRY_RECORD_LIMIT = 200; @@ -83,13 +93,14 @@ export function describeIdleExpiry(record: IdleExpiryRecord): string { const who = record.sessionId === record.rootId ? `Session ${record.sessionId}` : `Session ${record.sessionId} (in the tree of ${record.rootId})`; - const program = record.outcome === 'released' - ? 'The process it was attached to was released and keeps running.' - : 'Its program was ended.'; + // The idle time in the unit the window is stated in, so the two read together. + const idle = record.windowMs % 60_000 === 0 + ? `${Math.floor(record.idleMs / 60_000)} min` + : `${Math.round(record.idleMs / 1000)} s`; return `${who} was closed by the idle policy at ${new Date(record.closedAt).toISOString()}: ` + - `no tool call named it or its tree for ${describeIdleWindow(record.idleMs)} ` + + `no tool call named it or its tree for ${idle} ` + `(the window is ${describeIdleWindow(record.windowMs)}, ${DEBUG_SESSION_IDLE_ENV}=${record.windowMs}). ` + - `${program} Create a new session to continue.`; + `${OUTCOME_SENTENCE[record.outcome]} Create a new session to continue.`; } /** How long a window is, in the unit a reader thinks in. */ diff --git a/src/session/session-manager-core.ts b/src/session/session-manager-core.ts index f037befa..7a4b3fe3 100644 --- a/src/session/session-manager-core.ts +++ b/src/session/session-manager-core.ts @@ -589,11 +589,31 @@ export abstract class SessionManagerCore extends EventEmitter { `no tool call named it or its tree for ${Math.round(windowMs / 1000)}s` ); const ownerKey = root.ownerKey; + // Which sessions of the tree had no running program when the policy came + // for it: never launched, or already over. Read now, at the decision — + // the close removes the rows, and a child that ends during the cascade + // was running when the policy decided. A child an earlier detach + // released is not one of them: its row is stopped, its process runs on. + // (The attach root that detach left behind is: nothing is attached to + // it any more, so it reads "closed" beside its children's "released".) + const withoutProgram = new Set( + [root, ...this.descendantsDeepestFirst(root.id)] + .filter((session) => + (session.state === SessionState.CREATED || isTerminalSessionState(session.state)) && + session.programEndExplanation?.data?.released !== true) + .map((session) => session.id) + ); const report = await this.closeSessionReporting(root.id); closed.push(root.id); if (report.closed) { - // An attach root's close releases its targets; a launch root's ends its program. - const outcome = report.released ? 'released' : 'ended'; + // An attach root's close releases its targets; a launch root's ends its + // program; a session with nothing running had nothing done to it. + const outcomeOf = (sessionId: string): IdleExpiryRecord['outcome'] => { + if (withoutProgram.has(sessionId)) { + return 'closed'; + } + return report.released || report.releasedSessionIds?.includes(sessionId) ? 'released' : 'ended'; + }; for (const sessionId of report.closedSessionIds) { this.recordIdleExpiry({ sessionId, @@ -602,13 +622,17 @@ export abstract class SessionManagerCore extends EventEmitter { closedAt: now, idleMs, windowMs, - outcome: report.released || report.releasedSessionIds?.includes(sessionId) ? 'released' : 'ended' + outcome: outcomeOf(sessionId) }); } const others = report.closedSessionIds.length - 1; + const did = { + ended: 'its program was ended', + released: 'its target was released and keeps running', + closed: 'it had no running program' + }[outcomeOf(root.id)]; this.logger.info( - `Idle debug session ${root.id} closed: ` + - (outcome === 'released' ? 'its target was released and keeps running' : 'its program was ended') + + `Idle debug session ${root.id} closed: ${did}` + (others > 0 ? ` (with ${others} child session${others === 1 ? '' : 's'})` : '') ); } diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json index 9e343937..d349a470 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json @@ -284,7 +284,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json index 7b59e446..07855d1d 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json @@ -284,7 +284,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json index d963fdf1..afe48fd6 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json @@ -284,7 +284,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json index 416e1e04..7c7dc4a6 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json @@ -284,7 +284,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json index d963fdf1..afe48fd6 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json @@ -284,7 +284,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json index 416e1e04..7c7dc4a6 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json @@ -284,7 +284,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json index e4b68c94..ceb8dff2 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json index 70c5582d..75ba24ff 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json index 90241a1b..0b58f602 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json index 68098fd9..c80e5243 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json index 90241a1b..0b58f602 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json index 68098fd9..c80e5243 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json index 3cc59691..5677ee44 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json @@ -280,7 +280,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json index f6aea1ef..64da0b7b 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json @@ -280,7 +280,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json index af990d49..da569a15 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json @@ -280,7 +280,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json index 379d8652..a1519f4e 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json @@ -280,7 +280,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json index af990d49..da569a15 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json @@ -280,7 +280,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json index 379d8652..a1519f4e 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json @@ -280,7 +280,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json index e4b68c94..ceb8dff2 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json index 70c5582d..75ba24ff 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json index 90241a1b..0b58f602 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json index 68098fd9..c80e5243 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json index 90241a1b..0b58f602 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json index 68098fd9..c80e5243 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json @@ -294,7 +294,7 @@ }, { "name": "restart_debugging", - "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. Works while running, paused, or after the program has exited. Not available for attach sessions or sessions never launched. Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", + "description": "Restart the debuggee: terminate the current program (if still running) and relaunch it with the same configuration as the last start_debugging. All current breakpoints are re-applied automatically. The get_output buffer starts fresh — read from since=0 after a restart. A Flutter session hot-restarts in place instead, an attached one included: same process, output buffer kept, and the answer carries hotRestart: true. Works while running, paused, or after the program has exited. Not available for sessions never launched, or for attach sessions of the other adapters (detach and re-attach). Answers like start_debugging, including \"running\" with pending:true when the relaunch has not stopped within the same short hold (then call wait_for_stop)", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/idle-expiry-answer.test.ts b/tests/core/unit/server/idle-expiry-answer.test.ts index 608b8358..201f6ef3 100644 --- a/tests/core/unit/server/idle-expiry-answer.test.ts +++ b/tests/core/unit/server/idle-expiry-answer.test.ts @@ -16,6 +16,7 @@ import { DebugMcpServer } from '../../../../src/server.js'; import { registerToolHandlers } from '../../../../src/server/tool-dispatch.js'; import { createProductionDependencies, type Dependencies } from '../../../../src/container/dependencies.js'; import { createMockDependencies } from '../session/session-manager-test-utils.js'; +import { describeIdleExpiry, type IdleExpiryRecord } from '../../../../src/session/idle-policy.js'; vi.mock('../../../../src/container/dependencies.js'); @@ -84,11 +85,41 @@ describe('what an agent sees of the idle policy (issue #1025, step 2)', () => { error: `Session ${id} was closed by the idle policy at 2026-11-10T12:06:00.000Z: ` + 'no tool call named it or its tree for 6 min (the window is 5 min, MCP_DEBUG_SESSION_IDLE_MS=300000). ' + - 'Its program was ended. Create a new session to continue.', + // The session was never launched: the answer does not claim a program was ended. + 'It had no running program by then. Create a new session to continue.', closedBy: 'idle-policy' }); }); + describe('the sentence', () => { + const record = (patch: Partial): IdleExpiryRecord => ({ + sessionId: 's1', + rootId: 's1', + closedAt: Date.parse('2026-11-10T12:06:00.000Z'), + idleMs: 360_000, + windowMs: 300_000, + outcome: 'ended', + ...patch + }); + + it('says what the close did to the program, one sentence per outcome', () => { + expect(describeIdleExpiry(record({ outcome: 'ended' }))).toContain(' Its program was ended. '); + expect(describeIdleExpiry(record({ outcome: 'released' }))).toContain(' The process it was attached to was released and keeps running. '); + expect(describeIdleExpiry(record({ outcome: 'closed' }))).toContain(' It had no running program by then. '); + }); + + it('states the idle time in the unit of the window, never a mix', () => { + // A window in whole minutes: the idle time is minutes too (390 s is not "390 s"). + expect(describeIdleExpiry(record({ idleMs: 390_000 }))).toContain('for 6 min (the window is 5 min,'); + // A window in seconds: so is the idle time. + expect(describeIdleExpiry(record({ idleMs: 100_400, windowMs: 90_000 }))).toContain('for 100 s (the window is 90 s,'); + }); + + it('names the root for a child the expiry took with it', () => { + expect(describeIdleExpiry(record({ sessionId: 'c1', rootId: 's1' }))).toContain('Session c1 (in the tree of s1) was closed by the idle policy'); + }); + }); + it.each(['step_over', 'wait_for_stop', 'close_debug_session', 'list_breakpoints'])('%s says it the same way', async (tool) => { build(); const id = await expire(); diff --git a/tests/core/unit/session/session-idle-policy.test.ts b/tests/core/unit/session/session-idle-policy.test.ts index a572f387..bdc71c68 100644 --- a/tests/core/unit/session/session-idle-policy.test.ts +++ b/tests/core/unit/session/session-idle-policy.test.ts @@ -9,7 +9,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { SessionManager, SessionManagerConfig } from '../../../../src/session/session-manager.js'; -import { DebugLanguage, JsDebugAdapterPolicy } from '@debugmcp/shared'; +import { DebugLanguage, JsDebugAdapterPolicy, SessionState } from '@debugmcp/shared'; import { createMockDependencies, overridePolicy } from './session-manager-test-utils.js'; const report = (reportId: string, pid = 4242) => ({ @@ -227,15 +227,96 @@ describe('SessionManager idle policy (issue #1025, step 2)', () => { }); }); + /** Mark a row an attach root, the way attach_to_process does. */ + const markAttached = (id: string): void => + (sessionManager as unknown as { sessionStore: { update(id: string, patch: object): void } }) + .sessionStore.update(id, { attachMode: true }); + it('says an attach root was released: its target keeps running', async () => { + // A live session (the mock launch stands in for a connected target). + const rootId = await startRoot(); + markAttached(rootId); + + await sessionManager.sweepIdle(WINDOW, { now: now() + WINDOW + 1 }); + + expect(sessionManager.idleExpiryOf(rootId, undefined)?.outcome).toBe('released'); + }); + + // "Its program was ended" is only true when there was one. A session the + // policy removes with nothing running in it says so instead. + it('says a session that never launched had no program: nothing was ended', async () => { const session = await sessionManager.createSession({ language: DebugLanguage.MOCK }); - // An attach root, the way attach_to_process marks the row. + + await sessionManager.sweepIdle(WINDOW, { now: now() + WINDOW + 1 }); + + expect(sessionManager.idleExpiryOf(session.id, undefined)?.outcome).toBe('closed'); + }); + + it('says a root whose program had already ended had none left to end', async () => { + const rootId = await startRoot(); + dependencies.mockProxyManager.simulateEvent('terminated'); + await vi.runAllTimersAsync(); + expect(sessionManager.getSession(rootId)!.state).toBe(SessionState.STOPPED); + + await sessionManager.sweepIdle(WINDOW, { now: now() + WINDOW + 1 }); + + expect(sessionManager.idleExpiryOf(rootId, undefined)?.outcome).toBe('closed'); + }); + + it('does not call an attach root\'s target released when the target was already gone', async () => { + const rootId = await startRoot(); + markAttached(rootId); + dependencies.mockProxyManager.simulateEvent('terminated'); + await vi.runAllTimersAsync(); + expect(sessionManager.getSession(rootId)!.state).toBe(SessionState.STOPPED); + + await sessionManager.sweepIdle(WINDOW, { now: now() + WINDOW + 1 }); + + expect(sessionManager.idleExpiryOf(rootId, undefined)?.outcome).toBe('closed'); + }); + + it('under a live attach root, a child that had already ended is not called released', async () => { + const rootId = await startRoot(); + markAttached(rootId); + const childId = await adoptChild(rootId); + dependencies.mockProxyManager.childViews.get(childId)!.simulateEvent('terminated'); + await vi.runAllTimersAsync(); + expect(sessionManager.getSession(childId)!.state).toBe(SessionState.STOPPED); + + await sessionManager.sweepIdle(WINDOW, { now: now() + WINDOW + 1 }); + + expect(sessionManager.idleExpiryOf(childId, undefined)?.outcome).toBe('closed'); + expect(sessionManager.idleExpiryOf(rootId, undefined)?.outcome).toBe('released'); + }); + + it('a launch root that had already ended, with a child still running: the root had no program, the child\'s was ended', async () => { + const rootId = await startRoot(); + const childId = await adoptChild(rootId); + dependencies.mockProxyManager.simulateEvent('terminated'); + await vi.runAllTimersAsync(); + expect(sessionManager.getSession(rootId)!.state).toBe(SessionState.STOPPED); + // The parent's end does not end the child: it is still a live session. + expect(sessionManager.getSession(childId)!.state).not.toBe(SessionState.STOPPED); + + await sessionManager.sweepIdle(WINDOW, { now: now() + WINDOW + 1 }); + + expect(sessionManager.idleExpiryOf(rootId, undefined)?.outcome).toBe('closed'); + expect(sessionManager.idleExpiryOf(childId, undefined)?.outcome).toBe('ended'); + }); + + it('a child released by an earlier detach is still recorded as released: its process runs on', async () => { + const rootId = await startRoot(); + const childId = await adoptChild(rootId); + // What the detach cascade leaves on a released child's retained row. (sessionManager as unknown as { sessionStore: { update(id: string, patch: object): void } }) - .sessionStore.update(session.id, { attachMode: true }); + .sessionStore.update(childId, { programEndExplanation: { note: 'Released', data: { released: true } } }); + dependencies.mockProxyManager.childViews.get(childId)!.simulateEvent('terminated'); + await vi.runAllTimersAsync(); + expect(sessionManager.getSession(childId)!.state).toBe(SessionState.STOPPED); await sessionManager.sweepIdle(WINDOW, { now: now() + WINDOW + 1 }); - expect(sessionManager.idleExpiryOf(session.id, undefined)?.outcome).toBe('released'); + expect(sessionManager.idleExpiryOf(childId, undefined)?.outcome).toBe('released'); }); it('records a released JavaScript child separately from its ended launch root', async () => { @@ -337,6 +418,16 @@ describe('SessionManager idle policy (issue #1025, step 2)', () => { expect.stringContaining(`Idle debug session ${rootId} closed: its program was ended`) ); }); + + it('the log does not claim a program was ended when none was running', async () => { + const session = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + + await sessionManager.sweepIdle(WINDOW, { now: now() + WINDOW + 1 }); + + expect(dependencies.mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining(`Idle debug session ${session.id} closed: it had no running program`) + ); + }); }); }); diff --git a/tests/e2e/docker/docker-smoke-python.test.ts b/tests/e2e/docker/docker-smoke-python.test.ts index 7b0b0ad2..b996782f 100644 --- a/tests/e2e/docker/docker-smoke-python.test.ts +++ b/tests/e2e/docker/docker-smoke-python.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import path from 'path'; import { fileURLToPath } from 'url'; import { buildDockerImage, createDockerMcpClient, hostToContainerPath, getDockerLogs } from './docker-test-utils.js'; -import { parseSdkToolResult } from '../smoke-test-utils.js'; +import { expectModernEraDebugCycle, parseSdkToolResult } from '../smoke-test-utils.js'; import type { Client } from '@modelcontextprotocol/client'; const __filename = fileURLToPath(import.meta.url); @@ -293,6 +293,31 @@ describe.skipIf(SKIP_DOCKER)('Docker: Python Debugging Smoke Tests', () => { console.log('[Docker Python] ✅ All checks passed'); }, 120000); // Increased timeout for Docker operations + // The cases around this one speak 2025-11-25 (the SDK client's default). The + // image serves both eras, and #1025 is not done until the packaged artifact + // is seen doing so: a second container, a client pinned to 2026-07-28. + it('serves the 2026-07-28 era from the image: discover, the full tool list, one Python breakpoint cycle', async () => { + const modernContainer = `mcp-debugger-py-era2026-${Date.now()}`; + const modern = await createDockerMcpClient({ + imageName: 'mcp-debugger:test', + containerName: modernContainer, + logLevel: 'debug', + protocol: 'modern' + }); + try { + await expectModernEraDebugCycle( + modern.client, + hostToContainerPath(path.join(ROOT, 'examples', 'python', 'simple_test.py')) + ); + } catch (error) { + console.log('[Docker Python] 2026-era container logs at failure:'); + console.log(await getDockerLogs(modernContainer)); + throw error; + } finally { + await modern.cleanup(); + } + }, 120000); + it('should handle multiple breakpoints in Docker', async () => { const hostScriptPath = path.join(ROOT, 'examples', 'python', 'simple_test.py'); const scriptPath = hostToContainerPath(hostScriptPath); diff --git a/tests/e2e/docker/docker-test-utils.ts b/tests/e2e/docker/docker-test-utils.ts index b066e64c..0313bb91 100644 --- a/tests/e2e/docker/docker-test-utils.ts +++ b/tests/e2e/docker/docker-test-utils.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from 'url'; import { mkdirSync } from 'node:fs'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { Client } from '@modelcontextprotocol/client'; +import { clientOptionsFor, stdioTransportFor, type SmokeProtocol } from '../../test-utils/helpers/smoke-protocol.js'; const execAsync = promisify(exec); @@ -30,6 +31,8 @@ export interface DockerTestConfig { forceRebuild?: boolean; /** Extra `docker run` arguments (e.g. ['--network', 'my-net']) */ extraRunArgs?: string[]; + /** The MCP protocol era the client speaks: `legacy` (default, 2025-11-25) or `modern` (pinned 2026-07-28). */ + protocol?: SmokeProtocol; } /** @@ -162,7 +165,7 @@ export async function createDockerMcpClient(config: DockerTestConfig = {}): Prom '--log-file', '/tmp/docker-test.log' ); - const transport = new StdioClientTransport({ + const transport = stdioTransportFor(config.protocol, { command: 'docker', args: dockerArgs, env: { @@ -170,13 +173,11 @@ export async function createDockerMcpClient(config: DockerTestConfig = {}): Prom NODE_ENV: 'test' } }); - + const client = new Client({ name: 'docker-test-client', version: '1.0.0' - }, { - capabilities: {} - }); + }, clientOptionsFor(config.protocol)); try { await client.connect(transport); diff --git a/tests/e2e/npx/npx-smoke-python.test.ts b/tests/e2e/npx/npx-smoke-python.test.ts index 118d96e0..9e60bc3b 100644 --- a/tests/e2e/npx/npx-smoke-python.test.ts +++ b/tests/e2e/npx/npx-smoke-python.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import path from 'path'; import { fileURLToPath } from 'url'; import { buildAndPackNpmPackage, installPackageGlobally, createNpxMcpClient, cleanupGlobalInstall, getPackageSize, verifyPackageContents } from './npx-test-utils.js'; -import { parseSdkToolResult } from '../smoke-test-utils.js'; +import { expectModernEraDebugCycle, parseSdkToolResult } from '../smoke-test-utils.js'; import type { Client } from '@modelcontextprotocol/client'; const __filename = fileURLToPath(import.meta.url); @@ -247,4 +247,16 @@ describe.sequential('NPX: Python Debugging Smoke Tests', () => { console.log('[NPX Python] ✅ All checks passed'); }, 120000); + + // The cases above speak 2025-11-25 (the SDK client's default). The installed + // bundle serves both eras, and #1025 is not done until the packaged artifact + // is seen doing so: a second server process, a client pinned to 2026-07-28. + it('serves the 2026-07-28 era from the installed bundle: discover, the full tool list, one Python breakpoint cycle', async () => { + const modern = await createNpxMcpClient({ logLevel: 'debug', protocol: 'modern' }); + try { + await expectModernEraDebugCycle(modern.client, path.join(ROOT, 'examples', 'python', 'simple_test.py')); + } finally { + await modern.cleanup(); + } + }, 120000); }); diff --git a/tests/e2e/npx/npx-test-utils.ts b/tests/e2e/npx/npx-test-utils.ts index 410b2a07..58cec1e9 100644 --- a/tests/e2e/npx/npx-test-utils.ts +++ b/tests/e2e/npx/npx-test-utils.ts @@ -10,6 +10,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { Client } from '@modelcontextprotocol/client'; +import { clientOptionsFor, stdioTransportFor, type SmokeProtocol } from '../../test-utils/helpers/smoke-protocol.js'; import fs from 'fs/promises'; import { appendFile, mkdir, writeFile } from 'fs/promises'; import { createHash } from 'crypto'; @@ -161,6 +162,8 @@ async function getCachedTarballPath(fingerprint: string): Promise export interface NpxTestConfig { logLevel?: string; + /** The MCP protocol era the client speaks: `legacy` (default, 2025-11-25) or `modern` (pinned 2026-07-28). */ + protocol?: SmokeProtocol; } /** @@ -316,7 +319,7 @@ export async function createNpxMcpClient(config: NpxTestConfig = {}): Promise<{ // Resolve the globally-installed CLI entry and run it directly via process.execPath. // This bypasses npx.cmd → cmd.exe resolution which fails on Windows with ENOENT. const cliEntry = await resolveGlobalCliEntry(); - const transport = new StdioClientTransport({ + const transport = stdioTransportFor(config.protocol, { command: process.execPath, args: [ cliEntry, @@ -359,9 +362,7 @@ export async function createNpxMcpClient(config: NpxTestConfig = {}): Promise<{ const client = new Client({ name: 'npx-test-client', version: '1.0.0' - }, { - capabilities: {} - }); + }, clientOptionsFor(config.protocol)); const randomOffset = Math.floor(Math.random() * 1000000); (client as unknown as { _requestMessageId: number })._requestMessageId = randomOffset; diff --git a/tests/e2e/smoke-test-utils.ts b/tests/e2e/smoke-test-utils.ts index ae80d0cb..44ae6a57 100644 --- a/tests/e2e/smoke-test-utils.ts +++ b/tests/e2e/smoke-test-utils.ts @@ -2,6 +2,9 @@ * Shared utilities for smoke tests */ import { Client, ServerResult } from '@modelcontextprotocol/client'; +import { expect } from 'vitest'; +import { TOOL_NAMES } from '../../src/server/tool-schemas.js'; +import { MODERN_PROTOCOL_VERSION } from '../test-utils/helpers/smoke-protocol.js'; /** * Parse SDK tool results @@ -189,3 +192,55 @@ export async function pollUntil( await new Promise(resolve => setTimeout(resolve, intervalMs)); } } + +/** + * The 2026-07-28 leg of a packaged-artifact smoke (issue #1025): the client was + * connected pinned to the modern era, so being here at all means the artifact + * answered `server/discover`. This asserts what was negotiated, that the whole + * tool surface is listed, and that one Python breakpoint cycle runs with no + * MCP session behind it — every request stands alone in this era. + */ +export async function expectModernEraDebugCycle(client: Client, scriptPath: string): Promise { + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION); + expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual([...TOOL_NAMES]); + + const created = parseSdkToolResult(await client.callTool({ + name: 'create_debug_session', + arguments: { language: 'python', name: 'era-2026-smoke' } + })); + const sessionId = created.sessionId as string; + expect(typeof sessionId).toBe('string'); + try { + const breakpoint = parseSdkToolResult(await client.callTool({ + name: 'set_breakpoint', + arguments: { sessionId, file: scriptPath, line: 11 } + })); + expect(breakpoint.success).toBe(true); + + const started = parseSdkToolResult(await client.callTool({ + name: 'start_debugging', + arguments: { sessionId, scriptPath, args: [], dapLaunchArgs: { stopOnEntry: false, justMyCode: true } } + })); + expect(started.success).toBe(true); + // A slow adapter can return before its first stop. That is a valid launch, + // not a failed smoke: use the same bounded wait a real client should use. + const stopped = started.pending === true + ? parseSdkToolResult(await client.callTool({ + name: 'wait_for_stop', + arguments: { sessionId, timeout: 25_000 } + })) + : started; + expect(stopped.state).toBe('paused'); + + const locals = parseSdkToolResult(await client.callTool({ + name: 'get_local_variables', + arguments: { sessionId, includeSpecial: false } + })); + const variables = locals.variables as Array<{ name: string; value: string }>; + expect(variables.find((variable) => variable.name === 'a')?.value).toBe('1'); + expect(variables.find((variable) => variable.name === 'b')?.value).toBe('2'); + } finally { + await client.callTool({ name: 'close_debug_session', arguments: { sessionId } }).catch(() => undefined); + } +} diff --git a/tests/integration/dev-proxy-eras.test.ts b/tests/integration/dev-proxy-eras.test.ts index 466d5d97..9b0f3e70 100644 --- a/tests/integration/dev-proxy-eras.test.ts +++ b/tests/integration/dev-proxy-eras.test.ts @@ -10,6 +10,7 @@ * never is. */ import { afterEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -137,6 +138,39 @@ describe('dev-proxy protocol eras (issue #1025, step 2)', () => { expect(await backendStarts()).toBe(1); }, 60_000); + // With negotiation on, nothing driven through the proxy speaks the 2025 era + // to a current backend any more. DEV_PROXY_BACKEND_ERA=legacy is how a sweep + // exercises it: same real server, no probe, `initialize` as the opening. + it('speaks the 2025 era to the same real server when DEV_PROXY_BACKEND_ERA=legacy', async () => { + const { client, backendStarts } = await connect('legacy', { + DEV_PROXY_BACKEND_CMD: `"${process.execPath}" "${path.join(root, 'tests/fixtures/dev-proxy/real-backend.mjs')}"`, + DEV_PROXY_BACKEND_ERA: 'legacy', + }); + + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining(['create_debug_session', ...devTools])); + + const status = await client.callTool({ name: 'dev_server_status', arguments: {} }); + expect(JSON.parse((status.content as Array<{ text: string }>)[0].text)).toMatchObject({ + state: 'running', backendTransport: 'stdio', protocolEra: 'legacy', protocolVersion: '2025-11-25', + }); + expect(await backendStarts()).toBe(1); + }, 60_000); + + it('refuses to start on an unknown DEV_PROXY_BACKEND_ERA, naming the variable on stderr', () => { + const run = spawnSync(process.execPath, [path.join(root, 'tools/dev-proxy/dev-proxy.mjs')], { + cwd: root, + env: { ...process.env, DEV_PROXY_ROOT: root, DEV_PROXY_BACKEND_ERA: 'modern' }, + input: '', + encoding: 'utf8', + timeout: 20_000, + windowsHide: true, + }); + + expect(run.status).not.toBe(0); + expect(run.stderr).toContain("DEV_PROXY_BACKEND_ERA is not a backend era; give 'auto' (default) or 'legacy'"); + }); + it('a 2026-07-28 client hears a backend resources/updated on its listen stream through the relay', async () => { const { client } = await connect({ pin: '2026-07-28' }); const updated: string[] = []; diff --git a/tests/test-utils/helpers/smoke-protocol.ts b/tests/test-utils/helpers/smoke-protocol.ts new file mode 100644 index 00000000..fc99d4d7 --- /dev/null +++ b/tests/test-utils/helpers/smoke-protocol.ts @@ -0,0 +1,36 @@ +/** + * Which MCP protocol era a packaged-artifact smoke speaks (issue #1025). + * + * The Docker image and the npx bundle serve both eras, and until this helper + * their smokes only ever spoke the older one: an SDK client's default opens + * with `initialize` (2025-11-25). `modern` pins 2026-07-28, so the same smoke + * can prove the packaged server answers `server/discover` and runs a debug + * session sessionlessly. + */ +import { StdioClientTransport, type StdioServerParameters } from '@modelcontextprotocol/client/stdio'; + +export type SmokeProtocol = 'legacy' | 'modern'; + +export const MODERN_PROTOCOL_VERSION = '2026-07-28'; + +/** + * A stdio transport the SDK negotiates on in place. For its exact base + * StdioClientTransport the SDK runs `server/discover` on a disposable sibling + * process spawned from the same parameters — here a second + * `docker run --name ` that collides with the first, or a second CLI + * process writing the same log file. A subclass probes on the connection + * itself (documented SDK behaviour; the dev proxy's BackendStdioTransport + * rests on the same rule). + */ +export class InPlaceStdioClientTransport extends StdioClientTransport {} + +export function stdioTransportFor(protocol: SmokeProtocol | undefined, params: StdioServerParameters): StdioClientTransport { + return protocol === 'modern' ? new InPlaceStdioClientTransport(params) : new StdioClientTransport(params); +} + +/** The SDK client options for the era: `modern` pins it, so a server that cannot speak it fails the connect instead of falling back. */ +export function clientOptionsFor(protocol: SmokeProtocol | undefined) { + return protocol === 'modern' + ? { capabilities: {}, versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } } } as const + : { capabilities: {} }; +} diff --git a/tests/unit/cli/http-command.test.ts b/tests/unit/cli/http-command.test.ts index 5ee8be4c..929581ce 100644 --- a/tests/unit/cli/http-command.test.ts +++ b/tests/unit/cli/http-command.test.ts @@ -1206,6 +1206,25 @@ describe('HTTP Command Handler', () => { expect(transport.close).toHaveBeenCalled(); } }); + + // The rule the idle-policy parser follows: name the setting, never copy the + // environment's contents into the log (a secret pasted into the wrong + // variable, or a newline that forges a log entry). + it.each([ + ['MCP_HTTP_STALE_SESSION_MS', 30 * 60_000], + ['MCP_HTTP_STREAM_LOST_SESSION_MS', 2 * 60_000], + ['MCP_HTTP_STALE_SWEEP_INTERVAL_MS', 60_000] + ] as const)('names a rejected %s without logging its value', async (name, fallback) => { + vi.useFakeTimers(); + fakeProc.env[name] = 'synthetic-secret-value\nforged-log-entry'; + const { handler } = createAppAndHandler(); + await initSession(handler); + + const warnings = vi.mocked(mockLogger.warn).mock.calls.map((call) => call.map(String).join(' ')); + expect(warnings).toContain(`Ignoring invalid ${name}; using default ${fallback}ms.`); + expect(warnings.join(' ')).not.toContain('synthetic-secret-value'); + expect(warnings.join(' ')).not.toContain('forged-log-entry'); + }); }); describe('handleHttpCommand', () => { diff --git a/tests/unit/dev-proxy/backend-env.test.ts b/tests/unit/dev-proxy/backend-env.test.ts index 8794795c..7650dd9f 100644 --- a/tests/unit/dev-proxy/backend-env.test.ts +++ b/tests/unit/dev-proxy/backend-env.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildBackendEnvironment, + resolveBackendEra, resolveBackendPort, resolveBackendTransport, updateBackendEnvOverrides, @@ -40,6 +41,37 @@ describe('dev-proxy backend transport (issue #1078)', () => { ); }); +// The proxy negotiates the era with its backend, so against a current backend +// it always lands on 2026-07-28 — and then nothing driven through the proxy +// exercises the 2025 era that most clients still speak. DEV_PROXY_BACKEND_ERA +// is the switch for that. +describe('dev-proxy backend era (issue #1025)', () => { + it('negotiates by default, and when DEV_PROXY_BACKEND_ERA says auto', () => { + expect(resolveBackendEra(undefined)).toBe('auto'); + expect(resolveBackendEra('')).toBe('auto'); + expect(resolveBackendEra('auto')).toBe('auto'); + }); + + it('speaks the 2025 era to the backend when asked for legacy', () => { + expect(resolveBackendEra('legacy')).toBe('legacy'); + }); + + it.each(['modern', '2025-11-25', 'Legacy', ' auto', 'synthetic-secret-value\nforged-log-entry'])( + 'refuses %j by naming the variable and its accepted values, not the value', + (raw) => { + expect(() => resolveBackendEra(raw)).toThrow("DEV_PROXY_BACKEND_ERA is not a backend era; give 'auto' (default) or 'legacy'"); + let message = ''; + try { + resolveBackendEra(raw); + } catch (error) { + message = (error as Error).message; + } + expect(message).not.toContain('synthetic-secret-value'); + expect(message.includes('\n')).toBe(false); + } + ); +}); + describe('dev-proxy backend environment overrides', () => { it.each([null, [], 'wrong', { FLAG: 1 }])('rejects an invalid env map: %j', (env) => { expect(() => updateBackendEnvOverrides({}, { env })).toThrow('env must be an object whose values are strings'); diff --git a/tests/unit/modern-smoke-cycle.test.ts b/tests/unit/modern-smoke-cycle.test.ts new file mode 100644 index 00000000..6acb4f9b --- /dev/null +++ b/tests/unit/modern-smoke-cycle.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Client } from '@modelcontextprotocol/client'; +import { expectModernEraDebugCycle } from '../e2e/smoke-test-utils.js'; +import { TOOL_NAMES } from '../../src/server/tool-schemas.js'; + +function fixture(start: Record, stop: Record = { state: 'paused' }) { + const callTool = vi.fn(async ({ name }: { name: string }) => { + const results: Record = { + create_debug_session: { sessionId: 'smoke' }, + set_breakpoint: { success: true }, + start_debugging: start, + wait_for_stop: stop, + get_local_variables: { variables: [{ name: 'a', value: '1' }, { name: 'b', value: '2' }] }, + close_debug_session: { success: true } + }; + return { content: [{ type: 'text', text: JSON.stringify(results[name]) }] }; + }); + const client = { + getProtocolEra: () => 'modern', + getNegotiatedProtocolVersion: () => '2026-07-28', + listTools: async () => ({ tools: TOOL_NAMES.map((name) => ({ name })) }), + callTool + } as unknown as Client; + return { client, callTool }; +} + +describe('modern artifact smoke launch timing', () => { + it('waits for a delayed breakpoint before reading locals', async () => { + const { client, callTool } = fixture({ success: true, state: 'running', pending: true }); + await expectModernEraDebugCycle(client, 'simple_test.py'); + expect(callTool.mock.calls.map(([request]) => request.name)).toEqual([ + 'create_debug_session', 'set_breakpoint', 'start_debugging', + 'wait_for_stop', 'get_local_variables', 'close_debug_session' + ]); + expect(callTool).toHaveBeenCalledWith({ + name: 'wait_for_stop', arguments: { sessionId: 'smoke', timeout: 25_000 } + }); + }); + + it('does not wait again when launch already observed the breakpoint', async () => { + const { client, callTool } = fixture({ success: true, state: 'paused' }); + await expectModernEraDebugCycle(client, 'simple_test.py'); + expect(callTool.mock.calls.map(([request]) => request.name)).not.toContain('wait_for_stop'); + }); + + it.each(['running', 'stopped'])('rejects a wait ending in %s and still closes the session', async (state) => { + const { client, callTool } = fixture({ success: true, state: 'running', pending: true }, { state }); + await expect(expectModernEraDebugCycle(client, 'simple_test.py')).rejects.toThrow(); + expect(callTool.mock.calls.map(([request]) => request.name)).not.toContain('get_local_variables'); + expect(callTool).toHaveBeenLastCalledWith({ + name: 'close_debug_session', arguments: { sessionId: 'smoke' } + }); + }); +}); diff --git a/tests/unit/scripts/canary-era-gate.test.ts b/tests/unit/scripts/canary-era-gate.test.ts new file mode 100644 index 00000000..a2f2be6a --- /dev/null +++ b/tests/unit/scripts/canary-era-gate.test.ts @@ -0,0 +1,39 @@ +/** + * The canary's 2026-07-28 legs run against published artifacts, and artifacts + * older than 0.27.0 cannot speak that era (issue #1025). The gate decides from + * the artifact's own version whether a 2026 leg is owed: skipped with a notice + * before 0.27.0, strict from it — and a prerelease of 0.27.0 is a 0.27.0 + * artifact, or the beta rehearsal of a release would skip the very check the + * release is waiting on. + */ +import { describe, expect, it } from 'vitest'; +import { FIRST_MODERN_ERA_RELEASE, servesModernEra } from '../../../scripts/canary-era-gate.mjs'; + +describe('canary era gate (issue #1025)', () => { + it('names 0.27.0 as the first release that serves 2026-07-28', () => { + expect(FIRST_MODERN_ERA_RELEASE).toBe('0.27.0'); + }); + + it.each(['0.27.0', '0.27.1', '0.28.0', '1.0.0', '10.2.3'])('%s owes the 2026 leg', (version) => { + expect(servesModernEra(version)).toBe(true); + }); + + it.each(['0.27.0-beta.1', '0.27.0-rc.2', '1.0.0-alpha.1'])('a prerelease counts as its release: %s owes the leg', (version) => { + expect(servesModernEra(version)).toBe(true); + }); + + it.each(['0.26.0', '0.26.9', '0.25.1', '0.9.99', '0.26.0-beta.1'])('%s predates the era: no leg owed', (version) => { + expect(servesModernEra(version)).toBe(false); + }); + + it('accepts a leading v, as a tag carries', () => { + expect(servesModernEra('v0.27.0')).toBe(true); + expect(servesModernEra('v0.26.0')).toBe(false); + }); + + // A version the gate cannot read must not be read as "old": that would turn + // a strict leg into a skipped one without anyone deciding so. + it.each(['', 'latest', 'beta', '0.27', 'x.y.z', '0.27.0.1', ' 0.27.0'])('refuses %j instead of guessing', (version) => { + expect(() => servesModernEra(version)).toThrow(/is not a release version/); + }); +}); diff --git a/tests/unit/scripts/canary-workflow.test.ts b/tests/unit/scripts/canary-workflow.test.ts index 0d47211e..6f2feb32 100644 --- a/tests/unit/scripts/canary-workflow.test.ts +++ b/tests/unit/scripts/canary-workflow.test.ts @@ -43,3 +43,37 @@ describe('canary.yml strict language legs (issue #1053)', () => { expect(env![1]).toContain("inputs.allow_missing_languages == true && '--skip-if-missing' || ''"); }); }); + +/** + * Every leg above opens with `initialize`: the 2025 era. From 0.27.0 the published artifacts + * serve protocol 2026-07-28 too (issue #1025), and the release is not done until the canary + * has seen the published npm bundle and the published image do it. So each job runs one more + * cycle in that era — added beside the 2025-era legs, never instead of them: most clients + * still speak 2025-11-25 to a new release. The driver skips the leg for an artifact older + * than 0.27.0 and holds everything from 0.27.0 (prereleases included) to it, deciding from + * the artifact's own version (scripts/canary-era-gate.mjs). + */ +describe('canary.yml 2026-07-28 legs (issue #1025)', () => { + const modern = smokeInvocations.filter((line) => line.includes('--era 2026-07-28')); + + it('runs one 2026-07-28 cycle in the npm job and one in the docker job', () => { + expect(modern).toHaveLength(2); + }); + + it('keeps every 2025-era leg: the 2026 cycles are additions', () => { + expect(smokeInvocations.length - modern.length).toBeGreaterThanOrEqual(8); + }); + + it('gates each 2026 cycle on the version of the artifact under test', () => { + for (const line of modern) { + expect(line).toContain('--artifact-version "$ARTIFACT_VERSION"'); + } + }); + + it('reads that version from the installed package in the npm job and from the pulled image in the docker job', () => { + expect(yml).toContain('ARTIFACT_VERSION="$(node -p "require(process.argv[1]).version" "$(npm root -g)/@debugmcp/mcp-debugger/package.json")"'); + expect(yml).toContain(`ARTIFACT_VERSION="$(docker run --rm --entrypoint node "$IMAGE" -p "require('/app/node_modules/@debugmcp/shared/package.json').version")"`); + expect(yml).not.toContain('npm view'); + expect(yml.split('\n').filter((line) => line.includes('echo "ARTIFACT_VERSION=$ARTIFACT_VERSION" >> "$GITHUB_ENV"'))).toHaveLength(2); + }); +}); diff --git a/tools/dev-proxy/README.md b/tools/dev-proxy/README.md index 277b4db3..82a48c90 100644 --- a/tools/dev-proxy/README.md +++ b/tools/dev-proxy/README.md @@ -177,6 +177,7 @@ Environment variables (all optional): | `DEV_PROXY_BUILD_TIMEOUT_MS` | `120000` | Build timeout in milliseconds; must be positive (invalid values use the default; values above 2147483647, about 24.8 days, are capped there) | | `DEV_PROXY_ROOT` | Auto-detected | Project root directory | | `DEV_PROXY_BACKEND_TRANSPORT` | `http` | Backend transport: `http` (default) or `stdio`; any other value refuses to start, naming the variable | +| `DEV_PROXY_BACKEND_ERA` | `auto` | The protocol era spoken to the backend: `auto` negotiates (a current backend lands on 2026-07-28, an older one on 2025-11-25); `legacy` opens with `initialize`, so a backend that serves both eras is exercised in the 2025 one. Any other value refuses to start, naming the variable. `dev_server_status` shows the era in force | | `DEV_PROXY_BACKEND_CMD` | Source CLI | Custom backend command, including `docker run ...` commands | | `DEV_PROXY_DISCOVERY_WAIT_MS` | `15000` | How long a request waits for a backend start or restart to settle. Keep it below the client's MCP startup timeout (30s in Claude Code); `0` disables the wait | | `DEBUG_MCP_NO_REDACT` | unset | Set on the stable proxy process to `1` or `true` to disable status-value redaction | diff --git a/tools/dev-proxy/backend-env.d.mts b/tools/dev-proxy/backend-env.d.mts index 069b7399..6de6fca4 100644 --- a/tools/dev-proxy/backend-env.d.mts +++ b/tools/dev-proxy/backend-env.d.mts @@ -16,6 +16,14 @@ export function resolveBackendPort(raw: string | undefined): number; */ export function resolveBackendTransport(raw: string | undefined): 'http' | 'stdio'; +/** + * The era the proxy speaks to its backend, from DEV_PROXY_BACKEND_ERA: unset, + * empty or `auto` negotiates; `legacy` opens with `initialize` (2025-11-25). + * Anything else throws an Error naming the variable and the accepted values, + * never the value. + */ +export function resolveBackendEra(raw: string | undefined): 'auto' | 'legacy'; + /** * Replace backend overrides only when a tool call explicitly supplies `env`. * Omission returns `current` unchanged (by identity); an empty `env` clears it. diff --git a/tools/dev-proxy/backend-env.mjs b/tools/dev-proxy/backend-env.mjs index 90bc82d9..85719044 100644 --- a/tools/dev-proxy/backend-env.mjs +++ b/tools/dev-proxy/backend-env.mjs @@ -57,6 +57,24 @@ export function resolveBackendTransport(raw) { ); } +/** + * The era the proxy speaks to its backend, from DEV_PROXY_BACKEND_ERA (issue + * #1025): unset, empty or `auto` negotiates — a current backend lands on + * 2026-07-28, an older one falls back to 2025-11-25. `legacy` skips the probe + * and opens with `initialize`: against a backend that serves both eras it is + * the only way anything driven through the proxy exercises the 2025 one. + * Anything else throws, naming the variable and the accepted values — not the + * value, so environment contents never reach a log. + * + * @param {string | undefined} raw + * @returns {'auto' | 'legacy'} + */ +export function resolveBackendEra(raw) { + if (raw === undefined || raw === '' || raw === 'auto') return 'auto'; + if (raw === 'legacy') return 'legacy'; + throw new Error("DEV_PROXY_BACKEND_ERA is not a backend era; give 'auto' (default) or 'legacy'"); +} + /** * Replace backend overrides only when a tool call explicitly supplies `env`. * Omission preserves the prior map; an empty object clears it. diff --git a/tools/dev-proxy/backend-stdio-transport.d.mts b/tools/dev-proxy/backend-stdio-transport.d.mts index aefa31b1..98745995 100644 --- a/tools/dev-proxy/backend-stdio-transport.d.mts +++ b/tools/dev-proxy/backend-stdio-transport.d.mts @@ -4,7 +4,7 @@ import { StdioClientTransport, type StdioServerParameters } from '@modelcontextp * The dev proxy's stdio transport to its backend: a subclass of the SDK's, so * `auto` negotiation probes on the backend's own pipe, that closes itself when * the proxy's shutdown signal aborts (under `auto` negotiation the SDK does not - * watch connect()'s signal while its probe is unanswered). + * watch connect()'s signal while its probe is unanswered: typescript-sdk #3133). */ export class BackendStdioTransport extends StdioClientTransport { constructor(params: StdioServerParameters, options?: { signal?: AbortSignal }); diff --git a/tools/dev-proxy/backend-stdio-transport.mjs b/tools/dev-proxy/backend-stdio-transport.mjs index d1c7aca3..3346f627 100644 --- a/tools/dev-proxy/backend-stdio-transport.mjs +++ b/tools/dev-proxy/backend-stdio-transport.mjs @@ -19,7 +19,10 @@ * would outlive the proxy (Linux CI caught exactly that). This class does what * the SDK does in `legacy` mode: it closes itself on that abort, which ends * the child and fails the pending negotiation at once, so the start's own - * failure path can clean up. + * failure path can clean up. Upstream knows: modelcontextprotocol/typescript-sdk + * issue #3133 ("Client.connect(): signal is ignored during the server/discover + * probe"), with a fix in review. Once a release carries it, the abort handling + * here can go; the subclass stays, for the in-place probe. * * Kept in its own module so unit tests can import it without executing the * dev-proxy entry point (dev-proxy.mjs runs main() at module top level). diff --git a/tools/dev-proxy/dev-proxy.mjs b/tools/dev-proxy/dev-proxy.mjs index a6ea7ea7..4190cb03 100644 --- a/tools/dev-proxy/dev-proxy.mjs +++ b/tools/dev-proxy/dev-proxy.mjs @@ -44,6 +44,7 @@ import { } from './docker-backend.mjs'; import { buildBackendEnvironment, + resolveBackendEra, resolveBackendPort, resolveBackendTransport, updateBackendEnvOverrides, @@ -73,6 +74,9 @@ const PROJECT_ROOT = process.env.DEV_PROXY_ROOT || path.resolve(__dirname, '..', // since-removed legacy transport's client silently. A throw here exits the // proxy with the message on stderr. const BACKEND_TRANSPORT = resolveBackendTransport(process.env.DEV_PROXY_BACKEND_TRANSPORT); +// `auto` negotiates the era with the backend; `legacy` opens with `initialize`, +// for exercising the 2025 era against a backend that serves both. +const BACKEND_ERA = resolveBackendEra(process.env.DEV_PROXY_BACKEND_ERA); const BACKEND_CMD = process.env.DEV_PROXY_BACKEND_CMD || null; const parsedTimeout = parseInt(process.env.DEV_PROXY_BUILD_TIMEOUT_MS || '', 10); @@ -583,13 +587,14 @@ class BackendManager { async _connectClient(command, args) { const closeState = { expected: false }; this.transportCloseState = closeState; - // The backend speaks both eras on both transports (issue #1025): negotiate. - // Over stdio that takes BackendStdioTransport (backend-stdio-transport.mjs), - // so the probe runs on the backend's own pipe rather than on a second - // backend process. + // The backend speaks both eras on both transports (issue #1025): negotiate, + // unless DEV_PROXY_BACKEND_ERA=legacy asked for the 2025 era outright. + // Over stdio negotiating takes BackendStdioTransport + // (backend-stdio-transport.mjs), so the probe runs on the backend's own + // pipe rather than on a second backend process. this.mcpClient = new Client( { name: 'dev-proxy', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' } } + { versionNegotiation: { mode: BACKEND_ERA } } ); // Relay backend resource notifications (resources/updated, list_changed)