Skip to content

feat: Serve 2026-07-28 MCP requests from a stateless adapter - #1165

Merged
jirispilka merged 13 commits into
refactor/parameterize-tool-compositionfrom
claude/stateless-v2-mcp-adapter-u2hg20
Jul 28, 2026
Merged

feat: Serve 2026-07-28 MCP requests from a stateless adapter#1165
jirispilka merged 13 commits into
refactor/parameterize-tool-compositionfrom
claude/stateless-v2-mcp-adapter-u2hg20

Conversation

@jirispilka

@jirispilka jirispilka commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

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 _meta envelope and never handshakes, so it cannot talk to us at all.

What changed

  • v2 SDK packages
  • New root export createStatelessServer(actorsMcpServer): builds one v2 Server per request (the pattern createMcpHandler's per-request factory is designed for) and serves tools/*, resources/* and prompts/* from a read-only per-request snapshot of the shared facade
  • Sibling of the private legacy adapter; each reads shared state only through its own narrow host interface.
  • dev_server.ts serves both protocol revisions on one endpoint via the SDK's isLegacyRequest; the stateful path is untouched. Adds DNS-rebinding protection (Host/Origin validation) and framing-before-auth ordering per SEP-2243

Out 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-internal consumes.

Deliberate behaviors worth knowing:

  • subscriptions/listen is 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.
  • Stateless instructions omit report-problem — the SDK answers server/discover from constructor-time instructions, which cannot reflect a per-request mode. The tool itself is still gated per request.
  • An anonymous stateless request is served report-problem by policyclient-info is optional on the 2026-07-28 envelope, and the blocklist only applies to declared client names.
  • Telemetry reports an empty mcp_session_id on 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 throwing close() is dead code.

Proof it works

  • Unit suite: 89 files, 1243 passed + 1 skipped (59 new tests). type-check, lint, format, check:agents clean.
  • Stateful/stateless parity asserted per tool category (INTERNAL, ACTOR, ACTOR_MCP) for tools/list and tools/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.
  • Official conformance suite (@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.
  • Live mcpc probe 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

@MQ37 MQ37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vojtechj-apify

Copy link
Copy Markdown
Contributor

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.

vojtechj-apify commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Code review — revised

Scope: 1149d2c...HEAD — the ActorsMcpServer split into a shared facade plus two protocol adapters (legacy_server.ts for the 2025-era v1 SDK, stateless_server.ts for 2026-07-28 via @modelcontextprotocol/server 2.0.0-beta.5), engine modules made protocol-neutral, both eras routed through the dev server's single POST endpoint.

Verification: pnpm run type-check, lint, test:unit (1243 passed), check:agents all pass. Traced every extracted call site (prepareToolCall, executeSyncToolCall, dispatchToolCall, executeToolAndUpdateTask, telemetry) against its pre-PR form — the legacy path looks behaviour-preserving, including the emitLog proxy, sendNotification binding, task cancel-watcher signal, and payment decoration (no double-decoration: sources hold raw entries, toStoredTool clones).

Blocker

3. High — src/dev_server.ts:162: server/discover is answered 401 without a token. The SDK invokes the factory for every modern route before registering server/discover, so the in-factory auth throw blocks negotiation entirely: a client cannot discover the server without already holding a token, and the 401 carries no WWW-Authenticate, so it cannot learn where to get one. Each probe also runs loadToolsFromUrl.

Should fix before merge

1. Medium — src/mcp/server.ts:354: report-problem client gate is bypassable on the stateless path. isReportProblemServable uses clientContext != null as "client known", but the v2 SDK's required envelope keys are only protocol-version + client-capabilitiesclient-info is optional, so the context is always non-null and isReportProblemBlockedForClient sees an empty name. A blocklisted client that omits io.modelcontextprotocol/client-info gets report-problem listed and callable. The control fails open. Gate on clientContext?.clientInfo?.name.

Nice to have

2. Low — src/dev_server.ts:252: non-JSON Content-Type misroutes modern traffic. express.json() leaves req.body = {}, so isStatelessRequest classifies the request legacy and answers 400 / -32000 "Mcp-Session-Id header is required" instead of the modern entry's 415. The SDK's own isLegacyRequest docs require an isJsonContentType check before dispatching either leg. Spec conformance and debuggability only — correct clients never hit it.

4. Low — src/mcp/server.ts:564-570: the ajvValidate teardown in close() was dropped — intentional?

Pre-PR (1149d2c), close() walked the tool map and nulled each compiled validator before clearing:

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 server / taskStore


Generated by Claude Code

@MQ37

MQ37 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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.

@jirispilka

jirispilka commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks!

  1. Keeping as-is: anonymous clients may see report-problem by policy. I was not sure about this but when ahead anyway. Client that does not identify itself correctly will get report-problem
  2. No change: couldn’t reproduce; beta.5 and 2.0.0 both return 415.
  3. Fixed locally: discovery is unauthenticated, skips tool loading, and 401s include WWW-Authenticate.
  4. Intentional: clearing the maps releases validators; nulling throws on frozen defaults. close() now also clears retained source maps.

claude and others added 13 commits July 28, 2026 13:36
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
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>
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.
@jirispilka
jirispilka force-pushed the claude/stateless-v2-mcp-adapter-u2hg20 branch from 7515432 to c2ee9a1 Compare July 28, 2026 11:40
@jirispilka
jirispilka merged commit 6aae6c7 into master Jul 28, 2026
15 checks passed
jirispilka added a commit that referenced this pull request Jul 28, 2026
…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>
@MQ37 MQ37 added the beta Create beta prereleases label Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

beta Create beta prereleases

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add MCP 2026-07-28 stateless server

5 participants