Repository navigation
fix(filesystem): revoke access when a roots update leaves no valid root - #5095
Conversation
Once the client's roots are in force, a roots/list_changed update with no valid roots now clears the allow-list instead of keeping the previous directories. Before any root has been in force, such an update keeps the command-line directories, as before. An empty allow-list now says so in its access-denied error. Closes #5094 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]>
🦋 Changeset detectedLatest commit: de49bdd The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
🟡 Changes recommended
The new error incorrectly says no roots are exposed when roots exist but are invalid.
1 open finding
What changed in this PR
Fixes filesystem access revocation when clients withdraw all valid roots.
Changes:
- Tracks when client roots replace CLI directories.
- Clears access after empty or invalid runtime root updates.
- Adds protocol-level tests, documentation, and a patch changeset.
| File | Description |
|---|---|
src/filesystem/server.ts |
Revokes access after roots are withdrawn. |
src/filesystem/lib.ts |
Improves empty allow-list errors. |
src/filesystem/README.md |
Documents roots revocation behavior. |
src/filesystem/__tests__/server-roots.test.ts |
Tests revocation, fallback, and restoration. |
src/filesystem/__tests__/server-paths.test.ts |
Updates the empty-list error assertion. |
.changeset/filesystem-revoke-empty-roots.md |
Records the patch release change. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
An update whose roots are all invalid still lists roots; the empty-allow-list message now says none of them is valid (Copilot on #5095). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]>
An update whose roots are all invalid still lists roots; the empty-allow-list message now says none of them is valid (Copilot on #5095). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit de49bdd) Signed-off-by: cliffhall <[email protected]>
|
Copilot round 1: 1 finding (medium), fixed in de49bdd (the message now says "the client exposes no valid roots"; the all-invalid case still lists roots). Gate EXIT=0. The client evidence in the body shows the earlier wording ("expose none"); the behavior is unchanged. Requesting round 2. |
| } else if (rootsInForce) { | ||
| allowedDirectories = []; | ||
| console.error( | ||
| "No valid root directories provided by client; access revoked until it exposes a root again", | ||
| ); |
There was a problem hiding this comment.
Agreed, and fixed in its own PR rather than widening this one: #5097 (issue) / #5098 (stacked on this PR). Each roots refresh takes a generation before roots/list, and its answer is applied only if no newer refresh has started, checked after both roots/list and validation; regression tests hold an answer back so they arrive out of order. The same fix is cherry-picked onto the release branch, stacked on #5096.
* fix(interface-diff): bound the job summary; watch the Windows helper (#4860) Copilot round 6 on #4975: - Cap the job summary below GitHub's 1 MiB limit, pointing to the artifact when the report is longer. - Add scripts/lib/win-shell-args.mjs, which the script imports, to the path filters. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(interface-diff): drop target_ref; keep both streams in errors (#4860) Copilot round 7 on #4975: - Remove the dispatch `target_ref` input: a target that predates the script has no `npm run interface-diff`, so the run failed. The head is the branch chosen in "Use workflow from". - A failed base install or build now reports the tail of both stdout and stderr, so tsc diagnostics are not lost behind an npm footer. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(interface-diff): refresh the lockfile after rebasing onto v2/main (#4860) v2/main no longer carries SDK v1 at the root (#5063), so mcp-server-diff's @modelcontextprotocol/sdk ^1.13.2 now resolves to its own dev-only copy. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * time: accept only exact IANA timezone keys on every platform On macOS, zoneinfo resolves a key such as europe/warsaw through the case-insensitive APFS tz database, so the server accepted it where Linux rejects it, and test_get_current_time_errors failed locally. Check each key against available_timezones() in the tool path and in the --local-timezone validation. Closes #5060 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * test(memory): expect Node 26's path-suffixed EISDIR message Node 26 appends the file path to the EISDIR read error, so the exact-match assertion in persistence.test.ts failed there. Expect the suffixed text on Node 26 and later and the bare text before, keeping the exact match. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin proxy-addr to ^2.0.8 (GHSA-jqcg-44mw-7w3h) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin ip-address to ^10.7.1 (GHSA-mwp4-54f8-5fhr and six more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin brace-expansion to ^5.0.12 (GHSA-rgw5-rvv9-x895 and three more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): raise the hono override to >=4.13.7 (GHSA-8j4g-w8fx-2239 and seven more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): raise the qs override to >=6.16.0 (GHSA-x5fp-wj9c-mxmx, GHSA-4mjr-xmp4-gh2g) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin nanoid to ^3.3.18 (GHSA-2v37-7h3g-55p8) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin source-map-js to ^1.2.2 (GHSA-68fv-2mgg-jv7q) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): bump vitest and @vitest/coverage-v8 to ^4.1.11 (GHSA-82fw-gwwq-j7x9) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(fetch): require protego>=0.6.2 (PYSEC-2026-3906) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(git): require gitpython>=3.1.60 (PYSEC-2026-3783 and fourteen more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(python): lock cryptography 50 (PYSEC-2026-3552) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(python): lock pyjwt 2.15 (PYSEC-2026-4140 and twelve more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(fetch): lock urllib3 2.8 (PYSEC-2026-4175, -4176, -4177) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(fetch): lock soupsieve 2.10 (PYSEC-2026-4170, -4171) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(contributing): sync with v2/main after 1cdd4a70 Brings main's CONTRIBUTING.md byte-identical to v2/main's, so the v1.0.0 milestone merge (#5080) produces v2/main's tree without a conflict. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore: fold the pending changesets into a 1.0.0 CHANGELOG entry per TypeScript server Generated by the repo's version-packages script (changeset version with @changesets/changelog-github), then each new 2.0.0 heading retitled 1.0.0 and the package.json and lockfile changes discarded: all four servers already read 1.0.0, which has never been published. Every pending changeset is consumed, so the next Version Packages PR bumps only for modern-era work. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore: credit @cliffhall without the Thanks prefix in the 1.0.0 changelogs Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore: stamp Python CalVer versions for 2026.10.10 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * time: accept only exact IANA timezone keys on every platform On macOS, zoneinfo resolves a key such as europe/warsaw through the case-insensitive APFS tz database, so the server accepted it where Linux rejects it, and test_get_current_time_errors failed locally. Check each key against available_timezones() in the tool path and in the --local-timezone validation. Closes #5060 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit d52c223121209f44c30ff867d9fc4665155f91de) Signed-off-by: cliffhall <[email protected]> * test(memory): expect Node 26's path-suffixed EISDIR message Node 26 appends the file path to the EISDIR read error, so the exact-match assertion in persistence.test.ts failed there. Expect the suffixed text on Node 26 and later and the bare text before, keeping the exact match. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit d7388c6408754374b034d6a104094159c145b69d) Signed-off-by: cliffhall <[email protected]> * test(time): use SDK 1.x's McpError in the #5060 test The cherry-pick of d52c2231 (#5077) comes from the SDK v2 tree, where the exception is MCPError. On v2/release/1.0.0 the server raises mcp.shared.exceptions.McpError, already imported by this test module. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin proxy-addr to ^2.0.8 (GHSA-jqcg-44mw-7w3h) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin ip-address to ^10.7.1 (GHSA-mwp4-54f8-5fhr and six more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin brace-expansion to ^5.0.12 (GHSA-rgw5-rvv9-x895 and three more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): raise the hono override to >=4.13.7 (GHSA-8j4g-w8fx-2239 and seven more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): raise the qs override to >=6.16.0 (GHSA-x5fp-wj9c-mxmx, GHSA-4mjr-xmp4-gh2g) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin fast-uri to ^3.1.8 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin nanoid to ^3.3.18 (GHSA-2v37-7h3g-55p8) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): pin source-map-js to ^1.2.2 (GHSA-68fv-2mgg-jv7q) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): require @modelcontextprotocol/sdk ^1.31.0 (GHSA-6qxp-vccf-f47h) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(deps): bump vitest and @vitest/coverage-v8 to ^4.1.11 (GHSA-82fw-gwwq-j7x9) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(fetch): require protego>=0.6.2 (PYSEC-2026-3906) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(git): require gitpython>=3.1.60 (PYSEC-2026-3783 and fourteen more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(python): lock anyio 4.15 (anyio advisory fixed in 4.14.2) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(python): lock cryptography 50 (PYSEC-2026-3552) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(python): lock pyjwt 2.15 (PYSEC-2026-4140 and twelve more) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(fetch): lock urllib3 2.8 (PYSEC-2026-4175, -4176, -4177) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(fetch): lock soupsieve 2.10 (PYSEC-2026-4170, -4171) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore: fold the pending changesets into a 1.0.0 CHANGELOG entry per TypeScript server Generated by the repo's version-packages script (changeset version with @changesets/changelog-github) on v2/release/1.0.0, then each new 1.0.1 heading retitled 1.0.0 (all four servers already read 1.0.0, never published), attributions shortened to the author link, and the package.json and lockfile changes discarded. Every pending changeset is consumed. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore: stamp Python CalVer versions for 2026.10.10 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(release): warn against Update branch on the milestone merge PR GitHub always offers Update branch on the merge PR, since main holds commits the merge branch lacks. Clicking it adds a merge of main to the branch, so the PR is no longer a pure merge. Says so next to the merge-commit warning, with the recovery. Closes #5092 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(release): reset to the update merge's first parent, not origin/v2/main origin/v2/main may have moved past the commit the ledger verified; the update merge's first parent is the branch head from before the click. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): revoke access when a roots update leaves no valid root Once the client's roots are in force, a roots/list_changed update with no valid roots now clears the allow-list instead of keeping the previous directories. Before any root has been in force, such an update keeps the command-line directories, as before. An empty allow-list now says so in its access-denied error. Closes #5094 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): revoke access when a roots update leaves no valid root Once the client's roots are in force, a roots/list_changed update with no valid roots now clears the allow-list instead of keeping the previous directories. Before any root has been in force, such an update keeps the command-line directories, as before. An empty allow-list now says so in its access-denied error. Closes #5094 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit 5ea217b57e042714c6cb29bb03502ce1c6d638c4) Signed-off-by: cliffhall <[email protected]> * chore(filesystem): fold the #5094 changeset into the 1.0.0 CHANGELOG The release branch's changesets were folded into 1.0.0 by #5088, and 1.0.0 is unpublished, so the cherry-picked fix is recorded in that entry rather than as a pending changeset. No version change. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): say the client exposes no valid roots, not none An update whose roots are all invalid still lists roots; the empty-allow-list message now says none of them is valid (Copilot on #5095). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): say the client exposes no valid roots, not none An update whose roots are all invalid still lists roots; the empty-allow-list message now says none of them is valid (Copilot on #5095). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit de49bdd27bb85ad889ed43d3b67aafb5deaca4d6) Signed-off-by: cliffhall <[email protected]> * fix(filesystem): apply only the newest roots refresh's answer Each roots refresh (the initial load and every roots/list_changed) takes a generation before it asks for roots; its answer is applied only if no newer refresh has started, checked after both roots/list and validation. A stale answer is logged and discarded, so it cannot undo a newer update such as a revocation. Closes #5097 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): apply only the newest roots refresh's answer Each roots refresh (the initial load and every roots/list_changed) takes a generation before it asks for roots; its answer is applied only if no newer refresh has started, checked after both roots/list and validation. A stale answer is logged and discarded, so it cannot undo a newer update such as a revocation. Closes #5097 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit 423fe6b7fca01b186443b5e21bc7440fe431bb64) Signed-off-by: cliffhall <[email protected]> * chore(filesystem): fold the #5097 changeset into the 1.0.0 CHANGELOG As with #5094 on this branch: 1.0.0 is unpublished and its changesets are already folded, so the cherry-picked fix is recorded in that entry. No version change. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): keep tool calls waiting for the update that overtook the initial roots When a roots/list_changed overtakes the initial roots/list, the initial refresh's stale answer is discarded, but it used to release the tool calls waiting on the initial load, which then ran against the command-line directories before the newer roots applied. The overtaking refresh now replaces the initialization gate while the initial load is pending, and waiting calls follow it until it settles (Copilot on #5098). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): keep tool calls waiting for the update that overtook the initial roots When a roots/list_changed overtakes the initial roots/list, the initial refresh's stale answer is discarded, but it used to release the tool calls waiting on the initial load, which then ran against the command-line directories before the newer roots applied. The overtaking refresh now replaces the initialization gate while the initial load is pending, and waiting calls follow it until it settles (Copilot on #5098). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit 564e84d6ae05061ad269cb26511bdd2899e73224) Signed-off-by: cliffhall <[email protected]> * test(filesystem): expect SDK 1.x's wrapped roots/list error in the #5097 test The cherry-pick of 564e84d6 comes from the SDK v2 tree, where a client handler's error reaches the server as "gone"; SDK 1.x wraps it as "MCP error -32603: gone", as the existing re-fetch test on this branch already expects. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * test(filesystem): assert waiting calls are released before the initial answer arrives The failed-overtaking-refresh test released the held initial roots/list before asserting, so it would also pass if callers stayed blocked on it. Assert first, then release for cleanup (Copilot on #5098). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * test(filesystem): assert waiting calls are released before the initial answer arrives The failed-overtaking-refresh test released the held initial roots/list before asserting, so it would also pass if callers stayed blocked on it. Assert first, then release for cleanup (Copilot on #5098). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * test(filesystem): wait for the stale initial answer before teardown The failed-overtaking-refresh test returned right after releasing the held initial roots/list, so its answer handling and root validation could race afterEach's teardown. Wait for the stale answer to be discarded first (Copilot on #5099). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * test(filesystem): wait for the stale initial answer before teardown The failed-overtaking-refresh test returned right after releasing the held initial roots/list, so its answer handling and root validation could race afterEach's teardown. Wait for the stale answer to be discarded first (Copilot on #5099). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): hold tool calls while any roots refresh is pending A runtime roots/list_changed refresh now replaces the initialization barrier, as the initial load and an overtaking refresh already did, so tool calls that arrive while its roots/list is pending wait for the result instead of running against the previous allow-list, which could still include a root the client had just withdrawn. The initialRootsPending flag is no longer needed. Closes #5101 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit 92f9558fbf0dbd0f1fb67d223a02c3ad5c3ad6bf) Signed-off-by: cliffhall <[email protected]> * fix(filesystem): hold tool calls while any roots refresh is pending A runtime roots/list_changed refresh now replaces the initialization barrier, as the initial load and an overtaking refresh already did, so tool calls that arrive while its roots/list is pending wait for the result instead of running against the previous allow-list, which could still include a root the client had just withdrawn. The initialRootsPending flag is no longer needed. Closes #5101 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(filesystem): fold the #5101 changeset into the 1.0.0 CHANGELOG As for #5094 and #5097 on this branch: 1.0.0 is unpublished and its changesets are already folded. No version change. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): install each roots refresh barrier before its roots/list is sent Each roots refresh (and the initial load) now replaces the initialization barrier before it sends roots/list, a microtask later, rather than just after. The SDK currently dispatches the client's roots/list handler asynchronously, so a newer change announced from that handler already lands after the older barrier is installed (verified: removing the deferral fails no test); the ordering is now guaranteed by the server rather than by that SDK detail (Copilot on #5103). Tests: a call queued behind a runtime refresh that fails is released against the current roots (Copilot on #5102); a refresh whose roots/list answer announces another change keeps the newest barrier (documents the case; passes with or without the deferral today). Comment wording fixed (Copilot on #5102). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): install each roots refresh barrier before its roots/list is sent Each roots refresh (and the initial load) now replaces the initialization barrier before it sends roots/list, a microtask later, rather than just after. The SDK currently dispatches the client's roots/list handler asynchronously, so a newer change announced from that handler already lands after the older barrier is installed (verified: removing the deferral fails no test); the ordering is now guaranteed by the server rather than by that SDK detail (Copilot on #5103). Tests: a call queued behind a runtime refresh that fails is released against the current roots (Copilot on #5102); a refresh whose roots/list answer announces another change keeps the newest barrier (documents the case; passes with or without the deferral today). Comment wording fixed (Copilot on #5102). (cherry picked from commit dcaa6fe36081953c371cd41cb55b124219c6191d) Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): claim the initial roots load's generation before deferring it Deferring initialize() a microtask (so its barrier is installed before roots/list is sent) also deferred its generation claim. The initial load now takes its generation synchronously in oninitialized and only its request is deferred, so a roots/list_changed handled before that request runs always counts as newer (Copilot on #5102). The SDK currently runs the deferred initial load before a later notification's handler, so this ordering is not reachable today (moving the claim back fails no test); the claim makes it explicit. New test: an update announced right after initialize wins over the held initial roots. Also renames the re-entrant announcement test. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): claim the initial roots load's generation before deferring it Deferring initialize() a microtask (so its barrier is installed before roots/list is sent) also deferred its generation claim. The initial load now takes its generation synchronously in oninitialized and only its request is deferred, so a roots/list_changed handled before that request runs always counts as newer (Copilot on #5102). The SDK currently runs the deferred initial load before a later notification's handler, so this ordering is not reachable today (moving the claim back fails no test); the claim makes it explicit. New test: an update announced right after initialize wins over the held initial roots. Also renames the re-entrant announcement test. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): wake calls waiting on a replaced roots refresh Tool calls waited on the refresh promise itself, so a call that started waiting on refresh A stayed blocked on A's roots/list even after a newer refresh B had installed itself and applied the client's current roots; a slow or unanswered A stalled calls until its request timed out. The barrier is now separate from the refresh: each refresh installs a new barrier before it sends roots/list and settles it when it finishes, and installing one settles the barrier it replaces, so waiting calls wake and move on to the newest. This also makes the microtask deferral from the previous commits unnecessary (the barrier is installed synchronously). New test: a call waiting on an unanswered older refresh completes once a newer one finishes; it times out with the previous barrier (Copilot on #5103). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(filesystem): wake calls waiting on a replaced roots refresh Tool calls waited on the refresh promise itself, so a call that started waiting on refresh A stayed blocked on A's roots/list even after a newer refresh B had installed itself and applied the client's current roots; a slow or unanswered A stalled calls until its request timed out. The barrier is now separate from the refresh: each refresh installs a new barrier before it sends roots/list and settles it when it finishes, and installing one settles the barrier it replaces, so waiting calls wake and move on to the newest. This also makes the microtask deferral from the previous commits unnecessary (the barrier is installed synchronously). New test: a call waiting on an unanswered older refresh completes once a newer one finishes; it times out with the previous barrier (Copilot on #5103). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci(release): publish with Node 24's bundled npm instead of installing one OIDC trusted publishing needs npm >= 11.5.1, and Node 22 bundles npm 10.x, so publish-npm installed [email protected] next to id-token: write. It now runs an exactly pinned Node 24.21.0, whose bundled npm (11.19.0) meets the floor, and installs nothing; a step fails the job before publishing if the bundled npm is ever below 11.5.1. The build jobs keep Node 22. The release skill's note on pinning moves from the npm CLI to the Node release. Closes #5104 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci(release): publish with Node 24's bundled npm instead of installing one OIDC trusted publishing needs npm >= 11.5.1, and Node 22 bundles npm 10.x, so publish-npm installed [email protected] next to id-token: write. It now runs an exactly pinned Node 24.21.0, whose bundled npm (11.19.0) meets the floor, and installs nothing; a step fails the job before publishing if the bundled npm is ever below 11.5.1. The build jobs keep Node 22. The release skill's note on pinning moves from the npm CLI to the Node release. Closes #5104 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit b4d3a6fb5729c9040a597ed3f9cc31a96f72153d) Signed-off-by: cliffhall <[email protected]> * test(release): pin the publish job's npm floor check and its no-install shape scripts/release-npm-floor.test.mjs reads publish-npm out of release.yml, as release-dist-tag.test.mjs does, and runs the npm floor step with a stub npm on PATH: 11.5.1, 11.6.0, 11.19.0 and 12.0.0 pass, 11.5.0, 11.4.9, 10.9.9 and 9.0.0 fail. It also asserts an exact Node pin whose bundled npm meets the floor, the check ahead of the download and the publish, and no step that installs or runs a fetched package (comment lines ignored). The previous workflow fails all five; loosening the floor to 11.5.0 fails the refusal test (Copilot on #5105 and #5106). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * test(release): pin the publish job's npm floor check and its no-install shape scripts/release-npm-floor.test.mjs reads publish-npm out of release.yml, as release-dist-tag.test.mjs does, and runs the npm floor step with a stub npm on PATH: 11.5.1, 11.6.0, 11.19.0 and 12.0.0 pass, 11.5.0, 11.4.9, 10.9.9 and 9.0.0 fail. It also asserts an exact Node pin whose bundled npm meets the floor, the check ahead of the download and the publish, and no step that installs or runs a fetched package (comment lines ignored). The previous workflow fails all five; loosening the floor to 11.5.0 fails the refusal test (Copilot on #5105 and #5106). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> (cherry picked from commit 4d13264593602076f9e75df83d767d3e1c858c03) Signed-off-by: cliffhall <[email protected]> * test(release): pin the ./ tarball path npm publish needs npm publish reads a bare dir/file argument as GitHub owner/repo shorthand and tries to clone it over SSH; the MCP Inspector's first release through the same split publish job failed that way (modelcontextprotocol/inspector#2551). release.yml already passes ./release-artifact/*.tgz; this asserts it, so the ./ cannot be lost in an edit. Reproduced locally with Node 24.21.0's npm: the bare path exits 128 on 'git ls-remote ssh://[email protected]/release-artifact/...', the ./ path dry-run-publishes. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * test(release): pin the ./ tarball path npm publish needs npm publish reads a bare dir/file argument as GitHub owner/repo shorthand and tries to clone it over SSH; the MCP Inspector's first release through the same split publish job failed that way (modelcontextprotocol/inspector#2551). release.yml already passes ./release-artifact/*.tgz; this asserts it, so the ./ cannot be lost in an edit. Reproduced locally with Node 24.21.0's npm: the bare path exits 128 on 'git ls-remote ssh://[email protected]/release-artifact/...', the ./ path dry-run-publishes. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(fetch): annotate the fetch tool The fetch tool set no tool annotations, unlike every tool in the git and time servers, and AGENTS.md requires them on every tool that is added or changed. It now declares readOnlyHint: true, destructiveHint: false, idempotentHint: true and openWorldHint: true (the values proposed on #3572), so clients can apply allow-reads/gate-sends policies to it. Protocol test pins them in tools/list; README lists them. Closes #3572 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(fetch): annotate the fetch tool The fetch tool set no tool annotations, unlike every tool in the git and time servers, and AGENTS.md requires them on every tool that is added or changed. It now declares readOnlyHint: true, destructiveHint: false, idempotentHint: true and openWorldHint: true (the values proposed on #3572), so clients can apply allow-reads/gate-sends policies to it. Protocol test pins them in tools/list; README lists them. Closes #3572 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(changeset): restore the SDK v2 majors, drop fixes shipped in 1.0.0 The SDK v2 migration did not ship in 1.0.0, so its four major changesets come back with their original content (0c505a61^). The three filesystem roots changesets describe fixes cherry-picked into 1.0.0 (#5094, #5097, #5101) and would otherwise be listed again in 2.0.0's changelog. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci: guard main so a PR into it brings nothing not already on v2/main Adds scripts/verify-main-source.mjs and the main-source.yml workflow (pull_request_target into main), which fails unless the PR's head commit is on origin/v2/main. GitHub rulesets cannot restrict a PR's source branch, so this check, made required on main, is what enforces the milestone-merge-only rule in AGENTS.md. Closes #5119 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * test: pin main-source.yml's safety properties; name the guard as a gate exception Addresses Copilot round 1 on #5120. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(release): the admin escape hatch for a release that cannot be a pure merge Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci: bring the main-source guard to main ahead of the next milestone merge The guard (#5119, PR #5120 on v2/main) runs on pull_request_target, which loads the workflow and script from main. Bring the same three files here byte-for-byte, so the check can run on, and be required for, PRs into main before the next milestone merge carries them. Part of #5119 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(release): run the escape hatch's disable, merge and re-enable under one exit trap Addresses Copilot round 3 on #5120. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * feat(fetch,git,time): serve the 2026-07-28 era beside 2025-11-25 Each Python server's serve() now drives Server.run(), the SDK's dual-era loop, instead of the legacy-only serve_loop: a 2026-07-28 client is served sessionlessly (server/discover, per-request envelopes) and a 2025-11-25 client through the initialize handshake, the client's first request deciding which. No handler reads the session or calls back to the client, so tool and prompt behavior is identical in both eras; the SDK fills in the 2026-07-28 result fields (resultType, ttlMs, cacheScope). Tests: a tests/test_eras.py per server runs a real mcp 2.x Client, with mode pinned, against serve() over the stdio JSON-RPC path in each era. test_modern_era_is_not_served, #4851's placeholder, becomes a raw-wire pin of the server/discover answer. READMEs and the client-smoke skill say the Python servers serve both eras. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * feat: serve both protocol eras on stdio in filesystem, memory and sequentialthinking Each server's main() now serves its factory through the SDK's serveStdio entry, so the first message a client sends picks the connection's era: a 2026-07-28 client opens with server/discover and carries its version, identity and capabilities in each request's _meta; a 2025-era client opens with initialize, as before. - filesystem: Roots is deprecated as of 2026-07-28 and unreachable there (no initialize, no server-to-client roots/list), so a modern connection uses the command-line directories only. A server started with none answers each modern tool call with an error saying so. 2025-era Roots handling is unchanged. The README now recommends command-line directories (#4844). - memory: on 2026-07-28 graph updates are handed to the listen router, which delivers them only to a subscriptions/listen stream naming the URI; on 2025-era connections they still wait for resources/subscribe. - sequentialthinking: one thought history per connection, on either era. main() reports transport errors on stderr; serveStdio owns the transport. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * feat(everything): serve both protocol eras and demonstrate the 2026-07-28 features (WIP: tests) Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * test(everything): cover both protocol eras and the 2026-07-28 features - modern.test.ts: server/discover, the extensions capability, cache hints, resultType, MRTR tools in-band, requestState across rounds (with retries after bad input, -32021 and a tampered state's -32602), per-request log levels, subscriptions/listen notifications, minted resources. - tasks.test.ts: rewritten for the tasks extension (tasks/get, tasks/update, tasks/cancel) in place of the skipped 2025-11-25 tests. - streamable-http.test.ts: 2026-07-28 clients on the same endpoint, Mcp-Param-* header mirroring and -32020, the handler bus, body errors. - Existing suites updated for per-instance state (no session ids in output), the legacy shim's wire shape, and -32602 resource-not-found. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs: show which feature belongs to which protocol era; changesets for #4852 - everything: README and docs mark each feature 2026-07-28, 2025-era or both, describe the per-era transports and factory, the tasks extension, minted resources and the deprecations; the instructions name the era-only tools. - AGENTS.md: the rule for serving both eras without session-keyed state. - Minor changesets for the four TypeScript servers. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(client-smoke): the TypeScript servers now serve 2026-07-28 The protocol-eras section said no server implements server/discover. After with --protocol-era legacy and modern; the Python servers are still 2025-only until #4853. Also records what the Inspector CLI does with input requests (it declares no capabilities) and with task handles (it follows them), as observed on 2.9.0. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix: task timestamps, task capability gates, async form validation, stdio exit status Copilot round 1 on #5131: - tasks: lastUpdatedAt is when the task entered its status (a stage reached, an answer or a cancel), not when a poll noticed it. - simulate-research-query asks for a clarification only from a form-capable client; trigger-elicitation-request-async needs form-mode elicitation and trigger-sampling-request-async needs sampling, since the SDK does not check the requests a task carries in its status. - trigger-elicitation-request-async validates an accepted answer against its form, and keeps waiting on one that does not fit. - All four stdio entry points set exit code 1 when the transport fails, since serveStdio reports a start failure through onerror, not a rejection. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(everything): refuse task tools and tasks/* without the extension with -32021 Copilot round 2 on #5131: the tasks extension requires a -32021 error, with the extension in data.requiredCapabilities, for a client that does not declare it, both on a request that cannot be served without a task handle and on tasks/get, tasks/update and tasks/cancel. McpServer reports a thrown error from a tool handler as an isError result, so guardTaskTools wraps the tools/call handler McpServer installs; the factory no longer declares the tools capability in the constructor (the first registerTool advertises the same one), so the handler is installed after the guard. The sampling and form-elicitation requirements of the async task tools are answered the same way, replacing the isError results added in round 1. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore(release-notes): put the release-ledger line under the Packages table The ledger line came after every package's PR list, the Repository section and GitHub's trailer, so in v1.0.0 it sat below 102 PR lines and was moved by hand before publishing. assembleNotes() now emits it directly after the Packages table; the file header, the tests and the release skill's step 5a list follow. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(everything): tasks/update ignores non-outstanding answers; bound minted resources Copilot round 3 on #5131: - minted resources: the process keeps at most 100 and 64 MiB of content, dropping the oldest, so a burst of gzip-file-as-resource calls cannot exhaust memory before the ten-minute TTL frees anything. - tasks/update ignores answers to requests that are not outstanding (never issued, already answered, or sent while the task is not waiting) instead of refusing the update, as the extension's spec says a server SHOULD. - A re-asked request gets a new key (form-2, sampling-2): the spec forbids reusing a key once a response for it was delivered. - simulate-research-query takes a clarification only if it is one of the interpretations it offered. - memory: a test pins that a 2026-07-28 resources/subscribe gets -32601 from the SDK (the round's memory finding, declined). Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(everything): bound the task store; document the async task tools' needs Copilot round 4 on #5131: - The process keeps at most 100 tasks, dropping the oldest, and the task tools bound the strings a task keeps (topic 1,000 characters, prompt 10,000), so a burst of task-tool calls cannot exhaust memory before the TTL frees anything. - The instructions name the sampling and form-elicitation capabilities trigger-sampling-request-async and trigger-elicitation-request-async need beyond the tasks extension, and the -32021 a call without them gets. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(everything): catch failed list-change sends; require inputResponses on tasks/update Copilot round 5 on #5131: - The instance publisher sends list changes through the low-level Server methods and catches their failure, since McpServer's helpers start the same send and drop the promise. - tasks/update without inputResponses, which the extension's schema requires, is refused with -32602 instead of acknowledged. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(everything): refuse a minted resource over the byte budget; simulate-research-query is not read-only Copilot round 6 on #5131: - A single blob larger than MAX_MINTED_BYTES is refused rather than stored after evicting everything else; gzip-file-as-resource reports it as a tool error. - simulate-research-query creates a task (and a full store drops its oldest), so it no longer claims readOnlyHint. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(everything): keep live tasks until their TTL; acknowledge any cancel; bound minted names Copilot round 7 on #5131: - At MAX_TASKS a new task is refused with a tool error instead of evicting a live one, since the extension lets a server discard a task only after its TTL. - tasks/cancel acknowledges a known task whatever its status (cancellation is cooperative); a finished task stays as it ended. - Minted resources count their name and URI against the byte budget, and gzip-file-as-resource bounds its name at 255 characters. - features.md names the async task tools' full requirements; the sequentialthinking README's era sentence is completed. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(everything): answer a JSON primitive POST body as an invalid request, not a parse error Copilot round 8 on #5131: express.json is strict by default, so a body of valid JSON that is not an object or array (null, "x", 42) failed to parse and was answered -32700, which MCP reserves for text that is not JSON. The parser now accepts any JSON value and the SDK answers a primitive with -32600. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(everything): bound the answers a task keeps; correct the task-limit comment Copilot round 9 on #5131: - tasks/update refuses answers over MAX_ANSWER_BYTES (64 KiB serialized) with -32602, leaving the task unchanged. With MAX_TASKS this bounds what the task store holds, where the transports alone accept 10 MiB messages. - The readOnlyHint comment on simulate-research-query no longer says a full store drops its oldest task (round 7 made it refuse a new one instead). Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(everything): declare get-header-params' nullable regionHeader with anyOf The Inspector's schema-portability check warns on `type: ["string", "null"]`: legal JSON Schema, but a client that maps tool schemas onto a single-`type` dialect (the OpenAPI subset of Gemini function declarations) may reject the tool or drop the constraint. regionHeader is now `anyOf: [{ type: "string" }, { type: "null" }]`; null still validates. Part of #4852 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs: always start PR work in a new git worktree AGENTS.md's "When work begins" rule now creates the feature branch in a new git worktree rather than by switching branches in the primary checkout, which concurrent agent sessions share, and removes it once the PR merges or closes. pr-flow step 2 carries the recipe (a sibling worktree, --no-track, the npm install inline, stacked branches cut from the parent) and step 10 the removal. Mirrors modelcontextprotocol/inspector#2663. Closes #5138 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs: record the v2/main ruleset in AGENTS.md v2/main now has a repository ruleset, "v2/main: PRs only", that refuses deletion, force-pushes and direct pushes, with no bypass actors and no required approvals. No automation pushes to v2/main, so nothing needs a bypass. Record the rule next to the branch table, and note that the main guard's trust in v2/main's history rests on it. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(pr-flow): use the generic branch pattern in the worktree cleanup Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(release): cut the release branches into worktrees too The release skill's audit, hand-run CalVer and merge-branch recipes still ran git switch -c in the shared checkout, contradicting the new rule. Each now uses the pr-flow worktree form, and close-out removes them. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci: declare read-only GITHUB_TOKEN permissions in python.yml and typescript.yml Neither workflow declared a permissions block, so every job's token took the repository default. Scope it to contents: read in the file, which is all these jobs use. The other workflows already declare theirs. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs: add an AGENTS.md rule for pacing bulk GitHub writes GitHub's secondary rate limit refuses bulk writes while the primary quota still shows headroom, and a refused create can still land. Add the rule under "Pacing GitHub writes", and point contribution-model.md, issue-triage, board-ops and pr-flow at it instead of stating their own pacing. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(scripts): double backslashes before a quote in win-shell-args An argument ending in a backslash was quoted as "...\\", which the CRT parser reads as an escaped quote, swallowing the rest of the command line. Backslash runs before an embedded quote or the closing quote are now doubled, with tests including a round trip through a reference CRT parser. Closes #5111 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(sequentialthinking): pad the thought box's header row to the border The header row was drawn unpadded, so it sat 2 columns short of the border rows. Pad it to the border's width, measured on the visible text, as the thought row already is. Closes #5108 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(git): prefix git_show diff headers with a/ and b/ git_show printed bare paths in each file's --- / +++ header, so its output was not a patch that `git apply` or `patch -p1` accepts: their default strips one leading component, reading logic/a.py as a.py. The headers now match `git show`: a/<path> and b/<path>, with /dev/null unprefixed for an added or deleted side. Fix as proposed by @udbhav-shrinet on the issue. Closes #5107 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs: pace the triage and recovery loops under the hourly cap A 1 s or 5 s cadence passes 500 writes an hour within minutes, so both sustained loops now sleep 8 s (450 an hour). The triage loop also splits `gh pr close --comment` into a paced comment and close. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(issue-triage): pace step 0's comment and close as separate writes `gh issue close --comment` sends both writes back to back, so step 0 now comments, pauses, then closes, and the duplicate path paces its three writes the same way. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(skills): drop the restated pacing limits and pace step 0 at 8 s The skills now point to AGENTS.md "Pacing GitHub writes" rather than quoting its numbers, and issue-triage step 0 sleeps 8 s after every write, the same as the PR-close loop. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci: dispatch CI on the version PRs that automation opens The Version Packages and Prepare Python Release workflows push their PR branches with the workflow token, which starts no workflow, and since #5048 the TypeScript and Python workflows run on push only, so a close and reopen (which fires pull_request) never started them either. Both CI workflows now also accept workflow_dispatch, the one event that token can start, and both version workflows dispatch them on the PR's branch after opening or updating it. RELEASING.md and the release skill describe that path instead of close-and-reopen. Closes #5110 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci: validate the TypeScript servers on Node 22, 24 and 26 in parallel typescript.yml's validate job gains a node axis (22, 24, 26), so each package's format, lint, typecheck, build and tests run on every supported Node line as parallel matrix legs rather than serial steps. The other single-version jobs stay on Node 22, the oldest supported. The Dockerfiles move to node:24-alpine, and AGENTS.md, quality-gate.md and the local-dev skill name Node 24 as the development version and 22 as the oldest supported. No engines field is added or changed. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci: report dispatched CI runs on the PR as commit statuses A workflow_dispatch run's checks attach to the head commit but are left out of its status rollup, which is what a pull request reads: a probe commit with 29 successful dispatched check runs had a rollup of null. So a version PR would have shown no CI at all. On a dispatch, each CI workflow now posts a pending commit status at the start and the run's result at the end, which the rollup does include. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(quality-gate): list the Validate legs per Node in the stage table Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci: finalize a cancelled dispatched run's status, and pin the path A dispatched run cancelled for any reason other than supersession left its "(dispatched)" status pending forever (Copilot). The result job now runs under always(): it stays silent only when a newer dispatched run of the same workflow exists on the commit, and otherwise reports the cancellation as an error. scripts/version-pr-ci.test.mjs pins each link of the version-PR CI path, since nothing exercises it before a release. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore: drop "Thanks" from the changesets attribution, keep the author changelog-github wrote each line as "Thanks [@user]!"; nearly every changeset here is a maintainer's, and community credit lives in the release notes' Thanks section. A template keeps the PR, commit and author links. template is experimental upstream, so changelog-github is pinned exactly. Closes #5161 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore: version packages * chore: stamp Python CalVer versions for 2026.10.18 mcp-server-fetch, mcp-server-git and mcp-server-time 2026.10.10 -> 2026.10.18, for the v2.0.0 release. Each shipped source changes since its last stamp (the 2026-07-28 era, the fetch annotations, #5107 in git). Part of #5159 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix: raise the TypeScript servers' Node floor to 24 The four packages declared engines >=20 and said "Node.js 20 or later" in their READMEs and 2.0.0 CHANGELOG entries, while CI never ran Node 20 and the docs named 22 as the floor. Node 20 is end of life and 22 reaches it in April 2027, so the floor moves to 24: engines, @types/node, the README and CHANGELOG lines, the CI matrix (24 and 26) and every workflow's node-version, and the setup docs. 2.0.0 is not yet on npm, so its CHANGELOG entries are corrected in place rather than superseded by a new changeset. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci: run the main-source guard on Node 24 again (#5168) Re-applies #5169's change to main-source.yml on top of the merge with main, so it reaches main as an ordinary change to a shared file. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * feat(time): build on MCPServer; an unknown tool is JSON-RPC -32602 The time server moves from the SDK's low-level Server, wrapped to keep SDK v1's behavior, to the high-level MCPServer (#5174): - Each tool is a typed function. MCPServer derives the input schema, validates arguments, and returns the result model as structuredContent with an outputSchema, plus the same object as indented JSON text. - Anticipated failures (invalid or empty timezone, bad HH:MM, a time skipped by a clock change) raise ToolError: an isError result prefixed "Error executing tool <name>: ", in place of v1's "Error processing mcp-server-time query: " prefix and its jsonschema messages. - An unknown tool is a JSON-RPC -32602 error ("Unknown tool: <name>") in both eras, as both spec revisions require. MCPServer still answers it with isError (python-sdk#3659), so a middleware rejects the name first. - subscriptions=False: the tools never change, so neither era claims listChanged. MCPServer advertises empty prompts and resources. - The jsonschema dependency and the v1 wrappers are gone. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * feat(fetch): build on MCPServer; an unknown tool is a -32602 error (#5174) The fetch server moves from the SDK's low-level Server, with its SDK v1 compatibility wrappers, to the high-level MCPServer, so this reference server shows the API new servers should use: - The `fetch` tool and prompt are decorated functions. Their type hints and Field descriptions are the input schema and the prompt's arguments, and MCPServer validates calls against them. The non-URL parameters are strict, so a string is not coerced to the integer or boolean the schema declares. - Anticipated failures (robots.txt refusal, connection failure, HTTP error status, refused private address, unusable proxy) raise ToolError, which MCPServer returns as an isError result prefixed "Error executing tool fetch: ". - An unknown tool is now a JSON-RPC -32602 protocol error, as both spec revisions require. MCPServer still answers it with isError (python-sdk#3659), so a middleware rejects the name first; it also keeps -32602 for an unknown prompt and a prompt call without `url`, which MCPServer would answer with -32603. - subscriptions=False: the lists never change, so the 2026-07-28 era advertises listChanged: false. - The hand-rolled jsonschema validation and isError folding are gone, and so is the direct jsonschema dependency. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * feat(git): build the git server on MCPServer; unknown tool is -32602 The git server moves from the SDK's low-level Server, with its SDK v1 compatibility wrapper (hand-rolled jsonschema validation, every exception folded into an isError result), to the high-level MCPServer (#5174). Each tool is a typed function registered with @server.tool; its name, description, arguments, defaults, descriptions and annotations are unchanged. Only each input schema's top-level title differs, now derived from the function name. - An unknown tool is a JSON-RPC -32602 protocol error in both eras, as the spec requires. MCPServer still answers one with isError (python-sdk#3659), so a middleware rejects it before the lookup, which also keeps #4994's rule that the name is checked before repo_path. - Anticipated failures (the guards, GitError, gitdb's ODBError, and the builtins GitPython raises for failed lookups) are raised as ToolError, so the model still reads their message, now prefixed by MCPServer with "Error executing tool <name>: ". Anything else is a crash, reported generically. - subscriptions=False: the tool list never changes, so the 2026-07-28 capabilities advertise no listChanged. MCPServer always serves empty prompt and resource lists, so those capabilities now appear. - Argument validation errors are pydantic's, from MCPServer. - jsonschema is no longer a direct dependency. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(git): report a detached HEAD in git_create_branch instead of catching TypeError Catching TypeError for every git tool would show a real bug's text to the model as if it were an anticipated failure. The only TypeError the git tools anticipated was GitPython's `active_branch` on a detached HEAD, so git_create_branch now checks for that and asks for base_branch. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * test(git): expect the platform's form of / in the outside-repository message repo_path is a Path parameter, so on Windows "/" reaches the guard as "\\" and the message echoes that. Fixes Test git (Windows) on #5175. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(python): serialize git tool calls, delegate malformed names, require mcp 2.3 Copilot round 1 on #5175: - git: MCPServer runs a synchronous tool in a worker thread, so overlapping calls could interleave a check and a change on the shared index. A lock keeps one tool call at a time, as when tools ran on the event loop. - fetch: the unknown-name middleware sees raw params; a non-string name or non-mapping prompt arguments now go to the SDK's validation (-32602) instead of raising TypeError (-32603). - fetch, git, time: subscriptions=False needs SDK 2.3, so the floor is mcp>=2.3,<3 in pyproject, uv.lock and the READMEs. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * chore: stamp Python CalVer versions for 2026.10.19 fetch, git and time changed after #5165 stamped 2026.10.18 (the MCPServer migration, #5175), and 2026.10.18 was never published. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix(ci): a superseded dispatched run posts no status, whatever its jobs did dispatch-status checked for a newer dispatched run only in its cancellation branch, so a superseded run whose job had already failed posted `failure` over the newer run's status. The check now runs first, in both python.yml and typescript.yml. A behavioral test runs each job's script with a stub gh. Found by Copilot on #5166. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * test: skip the dispatch-status script tests without bash or jq The job's script calls jq; on a machine without it the latest-run case posts error instead of success, failing the gate over a missing test prerequisite. Copilot round 1 on #5179. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs: all work in a fresh worktree, with the session started inside it Widens the #5138 worktree rule from PR branches to all work, and adds the session-start requirement: Claude Code loads AGENTS.md and the skills once, from where the session starts, so a stale checkout means stale rules for the whole run. A SessionStart hook (scripts/session-instructions-check.mjs) checks it at every start. Closes #5167 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs(skills): keep argument placeholders out of skill bodies Claude Code substitutes $0-$9 and $ARGUMENTS in a skill's body when the skill is invoked with arguments, so board-ops's opt() reached the model with its field-name parameter rewritten and resolved no option id. - board-ops: opt() hands its arguments to jq with --args and reads $ARGS.positional, so it has no shell positional parameters. - verify:skills: parseSkill rejects $<digit> and $ARGUMENTS anywhere in a body, reporting each by file line. - docs/skill-authoring.md gains "Writing the body"; AGENTS.md states the rule under Maintaining the skills. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * fix: anchor the instructions check at the root, re-check from the start revision Copilot round 1 on #5219: the documented check's pathspecs were relative to the current directory, so run from src/git it looked for instructions under src/git; and a re-check against HEAD passes in a newer worktree while the session still holds the rules it started with. Pathspecs now use `:/`, and the hook prints the session-start revision and the exact re-check command built from it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * docs: link the Version Packages PR to its release issue, not only by comment The Version Packages PR is linked with addCloseIssueReferences as soon as it is found, carries Part of #N as its body's first line immediately before merging, and keeps the Part of #N comment as the durable record. Closes #5164 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]> * ci: dispatch DCO and the Everything diff on version PRs opened by automation The workflow token's pull_request events start no DCO or Everything diff run: GitHub holds each as action_required, or fails it with no jobs, so every regenerated Version Packages PR showed both red or pending until a maintainer closed and reopened it. Both workflows now accept workflow_dispatch and mirror a dispatched run onto the head commit as a "(dispatched)" status, as #5110 did for the CI workflows. Version Packages dispatches both; Prepare Python Release dispatches DCO only, since its stamps touch none of the diff's paths. A dispatched DCO run checks origin/v2/main..<branch tip> and is grouped by its branch, so the two version PRs' runs cannot cancel each other. Closes #5173 Co-Authored-By: Claude Opus 5.5 (1M …


Closes #5094
Description
In the filesystem server, a
notifications/roots/list_changedwhoseroots/listcame back with no valid root left the previous allow-list in force, so a client that withdrew its roots did not withdraw the server's access.updateAllowedDirectoriesFromRootsonly assigned whenvalidatedRootDirs.length > 0.Now:
roots/listresponse is unchanged.Access denied - no allowed directories: the client's roots expose none (<path>), instead of... not inwith nothing after it. This is the existing not-allowed branch ofvalidatePath, worded for its empty case.Found by Copilot on the v1.0.0 merge PR (#5090). Pre-existing on
main/2026.8.31; it is also cherry-picked onto the release branch for v1.0.0 (#5080).Server Details
server.ts), the access-denied message for an empty allow-list (lib.ts), README (Roots protocol handling), patch changesetMotivation and Context
#5094: roots are the client's statement of what the server may touch; an update that withdraws them must be honored.
How Has This Been Tested?
Protocol-level tests (
__tests__/server-roots.test.ts, in-memory transport, a real Client answeringroots/list): withdrawing the last root revokes access (and the command-line directories do not come back); every remaining root invalid revokes access; a root exposed again restores access; an empty update before any root was in force keeps the command-line directories. One existing test updated for the new empty-list message. Filesystem: 330 passed / 2 skipped;server.tscoverage 100 / 96.61 / 100 / 100.Client evidence: a raw JSON-RPC stdio client declaring
roots: { listChanged: true }(protocol 2025-11-25) that offers one root, callslist_directory, then answersroots/listwith[]afternotifications/roots/list_changed:npm run local:gate: EXIT=0.Breaking Changes
No configuration change. Behavior change for clients that withdraw their roots: access is now revoked, which is what the Roots model promises.
Types of changes
Checklist
roots/list,notifications/roots/list_changed)filesystem-revoke-empty-roots.md, patch)🤖 Generated with Claude Code