feat: Serve 2026-07-28 MCP requests from a stateless adapter - #1165
Conversation
a53bf60 to
659a4b8
Compare
659a4b8 to
7e41b48
Compare
6130986 to
a4283b7
Compare
MQ37
left a comment
There was a problem hiding this comment.
LGTM. I pushed a few small changes - one cosmetic, unifying the destructure to use const instead of let for every variable (only args actually mutates), and I removed removeToolsByName altogether since it's not used anymore. Otherwise no major blocking issues found and I think we can ship this.
|
The Issue 5 requires check on apify-mcp-server-internal, I am running that now. I would consider 3 a merge blocker as a client can't discover the server without already holding a token, and the missing WWW-Authenticate means it can't learn where to get one seems serious. The 1 and 4 should be fixed before merge. 2 is nice to have. |
Code review — revisedScope: Verification: Blocker3. High — Should fix before merge1. Medium — Nice to have2. Low — 4. Low — Pre-PR ( for (const tool of this.tools.values()) {
if (tool.ajvValidate && typeof tool.ajvValidate === 'function') {
tool.ajvValidate = null;
}
}
this.tools.clear();Retracted issue 5.: public→private break on
|
|
Bumped the v2 MCP SDK (@modelcontextprotocol/{core,node,server}) from 2.0.0-beta.5 to stable 2.0.0, now that it's released. |
|
Thanks!
|
A 2026-07-28 client sends each request with its own `_meta` envelope and no handshake. `createStatelessServer` builds one v2 SDK Server per request over a read-only snapshot of the shared facade, so mode and report-problem visibility resolve from that request alone and concurrent requests cannot contaminate each other. The dev server routes both eras through the same endpoint; claim-less requests keep the existing sessionful path unchanged. Tasks and subscriptions/listen stay out of scope. Stateless instructions omit report-problem, while the tool itself is still gated per request. Telemetry reports an empty session id on this path. Closes #1140 Part of #1128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DeTjFmDV3RK6ALz9MKZdPq
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 2026-07-28 client sends each request with its own `_meta` envelope and no handshake. `createStatelessServer` builds one v2 SDK Server per request over a read-only snapshot of the shared facade, so mode and report-problem visibility resolve from that request alone and concurrent requests cannot contaminate each other. The dev server routes both eras through the same endpoint; claim-less requests keep the existing sessionful path unchanged. Tasks and subscriptions/listen stay out of scope. Stateless instructions omit report-problem, while the tool itself is still gated per request. Telemetry reports an empty session id on this path. Closes #1140 Part of #1128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DeTjFmDV3RK6ALz9MKZdPq
…n stateless server
Review findings on the 2026-07-28 stateless adapter stack: - The stateless dev route resolved auth before the SDK's validation ladder, answering hostile-framing probes 401 instead of the SEP-2243 400/-32020. Auth now resolves inside the server factory, which the entry invokes only after validation passes — done to satisfy the http-header-validation conformance scenarios even on the dev server. - Wire the SDK's localhostHostValidation() next to the Origin guard: a rebound hostname arrives with no Origin and a foreign Host. - pendingToolsUntilClientKnown appended on every reload and is only drained by the legacy initialize flush, so a stateless-only facade grew without bound. Key it like toolSources so a reload replaces its entry instead of appending. - isMcpError() is a type predicate now; drops a manual cast. - Drop the unused @modelcontextprotocol/core direct dependency — nothing imports it and @modelcontextprotocol/server exact-pins it. - Document toolSourceKey's flat-Input ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…isions and their handling
Only `args` is ever reassigned in this handler; `name`/`meta` never
mutate. A single `let { ... }` destructure forced all three into one
mutable binding, requiring a blanket eslint-disable that would also hide
a real prefer-const violation on a future field. Split the const-only
fields out so the lint rule stays enforced.
Same fix as the stateless adapter's tools/call handler: only `args` is ever reassigned, so it doesn't need to share a mutable let-destructure (and its blanket eslint-disable) with name/meta, which never mutate.
No callers anywhere in this repo or in apify-mcp-server-internal — the internal repo already dropped its only call site (and the paired tools-changed-notification mechanism it fed) in #680 while prepping this same stateless migration, and this repo's own notification mechanism was already removed earlier. Deleting instead of updating it to also sync `toolSources`, since there's no live use case left to fix it for. Breaking change to the public `ActorsMcpServer` API surface (method removed). Verified zero consumers via grep across both apify-mcp-server and apify-mcp-server-internal (develop, current HEAD).
@modelcontextprotocol/{core,node,server} shipped their first stable
2.0.0 release (2026-07-27T23:55Z), aligning the 2026-07-28 wire format
with the final spec revision (clientInfo demoted to optional in the
envelope, serverInfo moved to the response _meta). beta.5's envelope
shape predates that revision and rejects conforming clients — tracked
in apify-mcp-server-internal#676's spec, which requires this repo's
pins to move first so the internal repo can follow in lockstep.
Added an explicit minimumReleaseAgeExclude entry for these three
packages, mirroring apify-mcp-server-internal's workspace config: the
v2 SDK is adopted at exact-pinned versions, so every bump here is
already a deliberate, reviewed change, and the repo's own 3-day age
gate (2.0.0 is currently <24h old) adds nothing on top of that. Drop
the exclusion once the pins move off exact versions.
type-check, lint, test:unit (89 files, 1243 passed, 1 skipped — no
regressions), format, and check:agents all pass unchanged.
7515432 to
c2ee9a1
Compare
…1163) ## Why Closing the server tried to modify read-only built-in tools and crashed before removing them. `close()` set each tool's compiled AJV validator to `null`, then cleared the tool map. Default tool entries are frozen module-level singletons, so assigning to `ajvValidate` throws in ESM strict mode: ```text TypeError: Cannot assign to read only property 'ajvValidate' ``` The exception stopped execution before `this.tools.clear()`. As a result, `ActorsMcpServer.close()` rejected and left the tool map populated. This bug already existed. It was found while building the 2026-07-28 stateless adapter. ## What changed The validator-nulling loop is deleted; `close()` now just clears the tool map. Review (thanks @MQ37) showed the loop did no useful work in any branch: - Frozen entries (all default tools) must be skipped — that write is the crash. - Payment-decorated clones share the frozen original's validator (`cloneToolEntry` restores it by reference), so nulling the clone frees nothing. - Per-Actor and proxied validators are already detached from the shared AJV instance at compile time (`fixedAjvCompile` does `removeSchema` + scope scrub), so `tools.clear()` alone makes them collectible. The regression test covers the crash: `close()` resolves and the tool map is empty afterwards, with a frozen built-in tool registered. ## Stacking This PR is stacked on #1155 rather than `master`. Two further PRs stack on this one (#1164, #1165); both are rebased on top of this change. ## Verification The regression test was written first and failed against the unmodified source with the `TypeError` above. It passes after the fix. The following checks pass on this commit: - `pnpm run type-check` - `pnpm run lint` - `pnpm run test:unit` - `pnpm run check:agents` - `pnpm run format:check` Unit test result: 84 files, 1178 passed, 1 skipped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
Why
Closes #1140. Part of #1128.
The server speaks only the 2025-era stateful protocol. A 2026-07-28 client carries protocol version, client info and capabilities in a per-request
_metaenvelope and never handshakes, so it cannot talk to us at all.What changed
createStatelessServer(actorsMcpServer): builds one v2Serverper request (the patterncreateMcpHandler's per-request factory is designed for) and servestools/*,resources/*andprompts/*from a read-only per-request snapshot of the shared facadedev_server.tsserves both protocol revisions on one endpoint via the SDK'sisLegacyRequest; the stateful path is untouched. Adds DNS-rebinding protection (Host/Origin validation) and framing-before-auth ordering per SEP-2243Out of scope: Tasks on 2026-07-28 and
subscriptions/listen(follow-ups tracked in one issue).Notes for reviewers ("human"-written)
Tested using MCP Inspector (v2/main branch) — all tools, widgets, and resources work.
Claude below:
Stacked on #1164 → #1163 → #1155. The only PR in the stack touching what
apify-mcp-server-internalconsumes.Deliberate behaviors worth knowing:
subscriptions/listenis not refused — the SDK answers it upstream of our handlers, opening a stream that can never emit. The dev server closes it per request; refusing it outright is follow-up work.report-problem— the SDK answersserver/discoverfrom constructor-time instructions, which cannot reflect a per-request mode. The tool itself is still gated per request.report-problemby policy —client-infois optional on the 2026-07-28 envelope, and the blocklist only applies to declared client names.mcp_session_idon this path; what a stateless "session" means for analytics is internal's call.upsertTools()/removeToolsByName()bypass the retained sources, so they change the stateful tool list only (documented in code; no callers today).close()now resolves instead of throwing (from fix: Drop validator nulling in close() that threw on frozen entries #1163) — any hosted teardown workaround built around a throwingclose()is dead code.Proof it works
type-check,lint,format,check:agentsclean.tools/listandtools/call, plus prompts, resources, telemetry fields, and v2 error projection. Concurrent requests with different identities resolve mode and gating independently.tasks/*answers method-not-found.@modelcontextprotocol/conformance@0.2.0-alpha.9,--spec-version 2026-07-28) run against the dev server; its two real findings (auth-before-framing, missing DNS-rebinding check) are fixed in this PR. Wiring it into CI with an expected-failures baseline is test: Add MCP 2026-07-28 integration and conformance coverage #1132.mcpcprobe against the legacy stdio server confirmed no v1 regression (initialize instructions, Actor round trip,resources/read, error paths, prompts, logging, Tasks, apps-mode widgets).Generated by Claude Code