Skip to content

feat: implement MCP 2026-07-28 alongside the handshake revisions - #170

Open
mcollina wants to merge 36 commits into
mainfrom
feat/mcp-2026-07-28
Open

mcollina wants to merge 36 commits into
mainfrom
feat/mcp-2026-07-28

Conversation

@mcollina

Copy link
Copy Markdown
Member

Summary

Implements the MCP 2026-07-28 specification, released 28 July 2026.

This is a much bigger change than a version bump — 2026-07-28 makes MCP stateless. It removes the initialize handshake, protocol-level sessions (Mcp-Session-Id), the standalone SSE GET endpoint, Last-Event-ID resumability, ping, logging/setLevel and resources/subscribe, and replaces server-initiated requests entirely.

All of those are load-bearing for current users of @platformatic/mcp, so rather than break them this implements the spec's dual-era server. Both protocols are served on the same /mcp endpoint:

  • A request carrying _meta['io.modelcontextprotocol/protocolVersion'] → served statelessly under 2026-07-28
  • Anything else → the existing initialize path, unchanged

Existing users need no changes. The handshake path and its 2025-11-25 / 2025-06-18 / 2025-03-26 / 2024-11-05 behaviour are untouched.

Business logic is shared between the eras: the modern dispatcher calls the same tool, resource and prompt handlers and only changes the envelope.

What's new

Feature Notes
server/discover Supported versions, capabilities and identity in one request
Per-request negotiation Version + capabilities in _meta; no handshake
Multi round-trip requests Handlers throw InputRequired, read context.inputResponses on the retry
subscriptions/listen Long-lived notification stream with per-type opt-in and acknowledgement
Cacheable results ttlMs / cacheScope on discover, the four lists and resources/read
Header routing Mcp-Method / Mcp-Name / Mcp-Param-* reconciled against the body, incl. the =?base64?…?= sentinel and x-mcp-header
Tasks extension io.modelcontextprotocol/tasks: tasks/get polling replaces blocking tasks/result, plus tasks/update

New modules

src/modern/
├── request-meta.ts     per-request _meta parsing; looksModern() era switch
├── headers.ts          header/body reconciliation
├── handlers.ts         modern dispatch, resultType envelope, caching, tasks extension
├── input-required.ts   handler-facing MRTR API (InputRequired, elicitForm, …)
├── request-state.ts    HMAC-sealed requestState
├── subscriptions.ts    subscriptions/listen streams
└── task-inputs.ts      delivers tasks/update responses to a running task
src/schema-2026.ts      types introduced or reshaped by the revision

Two things reviewers should weigh in on

requestState needs a shared secret in multi-instance deployments

MRTR state travels through the client, so the spec treats it as attacker-controlled and requires integrity protection. It's sealed with HMAC-SHA256 and bound to the authenticated principal, an expiry, and a digest of the originating request — tampered, expired, cross-principal and cross-request state are all refused.

The default is a per-process random key, which is correct for a single instance but means a retry landing on another replica is refused. Deployments behind a load balancer must set requestStateSecret. Replay is bounded, not eliminated; single-use semantics remain the handler's job, as the spec notes.

Caching defaults to off

{ ttlMs: 0, cacheScope: 'private' } — spec-compliant and always safe, but clients never cache until you opt in per operation. I chose this over guessing a TTL because cacheScope: 'public' lets shared proxies serve one caller's response to another even from an authenticated endpoint.

Behaviour changes

  • negotiateProtocolVersion falls back to 2025-11-25 rather than the newest revision — a client sending initialize cannot, by definition, speak 2026-07-28
  • mcpBroadcastNotification no longer requires enableSSE, since subscriptions/listen is core to the modern protocol (legacy SSE delivery is still gated by the flag)
  • Status codes now follow the revision, because dual-era clients use them to detect which era a server speaks:
    • 400 — HeaderMismatch (-32020), MissingRequiredClientCapability (-32021), UnsupportedProtocolVersion (-32022)
    • 404 + -32601 — removed or unknown methods
    • 200 — application failures (unknown tool, missing resource)

Testing

npm run ci passes: 472 tests (was 369), including the Redis-backed suites.

New coverage:

  • test/spec-2026-07-28.test.ts — end-to-end: discovery, metadata validation, header validation, removed methods, MRTR (including tampering and replay), subscriptions, tasks extension, and dual-era interleaving
  • test/modern-units.test.ts — header encoding, request-state sealing, subscription filters, _meta parsing

Not included

  • spec/ is refreshed to the 2026-07-28 documents
  • Package version not bumped — this is additive and non-breaking, so 2.2.0 seems right, but that's a maintainer call

🤖 Generated with Claude Code

https://claude.ai/code/session_01TdN2reiRNd6xsvtJVjHPCG

@mcollina

Copy link
Copy Markdown
Member Author

Adversarial review — findings and fixes

Ran a hostile review pass over this branch. It found real defects that the green suite missed; 5364406 fixes them. Every new test was verified to fail against the unfixed code.

Fixed

Sev Finding Fix
Critical Header/body mirroring was bypassable. Era detection keyed only off _meta[protocolVersion] in the body, so modern Mcp-Method/Mcp-Name headers + a body omitting _meta dropped onto the legacy path and skipped header validation entirely — calling one tool while a gateway routing on Mcp-Name saw another isModernRequest treats a modern MCP-Protocol-Version header as era-determining
High Graceful subscription closure never sent. closeAll() ran in onClose, which Fastify runs after shutting the server down Moved to preClose
High tasks/update lost across instances. Wake-up was process-local while answers were written to the shared store and inputRequests cleared — success ack, task never resumes, unrecoverable Delivery over the message broker, plus a bounded early-arrival buffer
Med-high A reused input key could never be answered. answeredInputKeys accumulated across rounds Cleared when a new round is issued
Medium MRTR retries carried caching hints, which spec/caching.md forbids — a cross-user leak with cacheScope: "public" Hints suppressed when inputResponses/requestState present
Medium URL-mode elicitation sent to form-only clients Mode checked against elicitation.url/.form separately
Medium Legacy revisions named in _meta were served a modern envelope Refused with -32022; full list still advertised
Low-med Mcp-Name not enforced when the body omitted name/uri Required for the three named methods regardless
Low-med requestState bound to undefined principal matched any other identity-less caller Refuses when authorization is enabled, matching assertTaskAccess

One shipped test asserted the critical bug as correct behaviour — replaced.

Not acted on

  • completion/complete advertised but 404s — the review said the legacy path implements it; it does not. This is the user's own capabilities config echoed back, same as on legacy initialize. Pre-existing, not a regression.
  • digestRequest collisions — stableStringify can collide ['a','b'] with {"0":"a","1":"b"}, but the reviewer could not turn it into a replay (every MRTR method needs an object params with a string name/uri, and the method is bound separately). Theoretical.
  • collectHeaderParams unbounded recursion — server-authored schemas only, not attacker-controlled. Worth memoizing later.
  • DELETE /mcp returns 404 rather than 405 when SSE is off — cosmetic.

Note on the deadlock claim

The review reported app.close() hanging on open SSE streams. I could not reproduce the hang on this Fastify version — but the substantive half is real and now tested: the graceful-closure response was never sent. Confirmed by reverting the fix (that test fails, the hang test does not).

Behaviour change worth flagging for release notes

LATEST_PROTOCOL_VERSION now means 2026-07-28. Sending it as the MCP-Protocol-Version header commits a request to the modern path, so existing code that hardcodes it needs LATEST_LEGACY_PROTOCOL_VERSION instead. Five legacy test suites needed exactly this change, which is decent evidence real users will too. Documented in the migration section.

484 tests pass (was 472), npm run ci green.

🤖 Generated with Claude Code

Comment thread src/routes/mcp.ts Outdated
Comment thread src/modern/task-inputs.ts Outdated
Comment thread src/modern/request-state.ts
@mcollina
mcollina force-pushed the feat/mcp-2026-07-28 branch from 5364406 to fb72c29 Compare August 20, 2026 00:18
mcollina and others added 5 commits August 30, 2026 18:31
The 2026-07-28 revision makes MCP stateless: it removes the
`initialize` handshake, protocol-level sessions, the standalone SSE GET
endpoint, `Last-Event-ID` resumability, `ping`, `logging/setLevel` and
`resources/subscribe`, and replaces server-initiated requests with
multi round-trip requests. All of those are load-bearing for existing
users, so this implements the spec's "dual-era" server rather than
breaking them: a request carrying
`_meta['io.modelcontextprotocol/protocolVersion']` is served
statelessly under the new revision, and anything else takes the
existing `initialize` path unchanged.

Business logic is shared between the eras — the modern dispatcher calls
the same tool, resource and prompt handlers and only changes the
envelope.

New in src/modern/:
- request-meta.ts  per-request `_meta` parsing; `looksModern()` era switch
- headers.ts       Mcp-Method / Mcp-Name / Mcp-Param-* vs body, with the
                   `=?base64?...?=` sentinel
- handlers.ts      modern dispatch, `resultType` envelope, caching hints,
                   `server/discover`, tasks extension
- input-required.ts  handler-facing MRTR API (`InputRequired`, `elicitForm`, ...)
- request-state.ts   HMAC-sealed `requestState`, bound to principal, expiry
                     and a digest of the originating request
- subscriptions.ts   `subscriptions/listen` streams with per-type opt-in
- task-inputs.ts     delivers `tasks/update` responses to a running task

Tasks move from the core protocol to the official
`io.modelcontextprotocol/tasks` extension: `tasks/get` polling replaces
the blocking `tasks/result`, `tasks/update` supplies mid-flight input,
`tasks/list` is gone. The 2025-11-25 core shape stays on the legacy path.

Status codes follow the revision, since dual-era clients use them to
detect which era a server speaks: 400 for HeaderMismatch (-32020),
MissingRequiredClientCapability (-32021) and
UnsupportedProtocolVersion (-32022); 404 with -32601 for removed or
unknown methods; application failures stay on 200.

Caching defaults to `{ ttlMs: 0, cacheScope: 'private' }` — spec-compliant
and safe — with opt-in configuration per operation.

`negotiateProtocolVersion` now falls back to 2025-11-25 rather than the
newest revision: a client sending `initialize` cannot speak 2026-07-28.
`mcpBroadcastNotification` no longer requires `enableSSE`, because
`subscriptions/listen` is core to the modern protocol.

spec/ is refreshed to the 2026-07-28 documents.

472 tests pass (was 369), including Redis-backed suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdN2reiRNd6xsvtJVjHPCG
An adversarial review of the 2026-07-28 implementation found several real
defects, all of which the green test suite missed.

**Header/body mirroring was bypassable (critical).** Era detection keyed
only off `_meta['io.modelcontextprotocol/protocolVersion']` in the body,
so a caller could send modern `Mcp-Method`/`Mcp-Name` headers with a body
that omits `_meta`, drop onto the legacy path, and skip header validation
entirely — calling one tool while a gateway routing on `Mcp-Name` saw
another. `isModernRequest` now treats a modern `MCP-Protocol-Version`
header as era-determining, so such a request earns `-32602` instead.

A shipped test asserted the buggy behaviour as correct; it has been
replaced with one that proves the smuggling attempt is refused.

**Subscription streams were never gracefully closed.** `closeAll()` ran
in `onClose`, which Fastify runs after shutting the HTTP server down, so
the empty `subscriptions/listen` response the spec asks for was never
sent. Moved to `preClose`. Covered by a new real-socket suite, since
`app.inject()` cannot observe socket lifecycle at all.

**`tasks/update` was lost across instances.** The wake-up went through a
process-local channel while the answers were written to the shared store
and `inputRequests` was cleared — so on any instance other than the one
running the task the client got a success ack, the task never resumed,
and no later update could revive it. Delivery now travels over the
message broker, with a bounded buffer for answers that arrive before the
task parks.

**A reused input key could never be answered.** `answeredInputKeys`
accumulated across rounds, so a second question under the same key was
filtered out as already-satisfied and the task hung until its ttl.
Cleared when a new round of questions is issued.

**MRTR retries carried caching hints**, which `spec/caching.md` forbids
outright — with `cacheScope: "public"` that is a cross-user leak through
a shared proxy.

**URL-mode elicitation was sent to form-only clients.** Mode is now
checked against `elicitation.url` / `elicitation.form` separately.

Also: legacy revisions named in `_meta` are refused on the modern path
rather than served a modern envelope; `Mcp-Name` is required for the
three named methods regardless of what the body contains; and
`requestState` refuses to bind to an undefined principal when
authorization is enabled, matching how tasks already treat a `sub`-less
token.

Legacy suites now pin `LATEST_LEGACY_PROTOCOL_VERSION`, since that
constant — not `LATEST_PROTOCOL_VERSION` — is what a handshake client
wants. Documented as a migration note.

Each new test was verified to fail against the unfixed code.

484 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdN2reiRNd6xsvtJVjHPCG
@mcollina
mcollina force-pushed the feat/mcp-2026-07-28 branch from 2b5eabd to 50dc07f Compare August 31, 2026 12:01
Comment thread src/routes/mcp.ts
: { onRequest: mcpOnRequest, preHandler: mcpPreHandler, schema: getSchema }

app.get('/mcp', routeOptions, async (request: FastifyRequest, reply: FastifyReply) => {
app.get('/mcp', getRouteOptions, async (request: FastifyRequest, reply: FastifyReply) => {

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.

Modern requests can still enter the legacy SSE GET /mcp handler, which creates a session and broker subscription. This contradicts the stateless 2026-07-28 protocol and allows unnecessary session allocation. We should reject modern GET/DELETE requests before creating or terminating a legacy session.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f498df5. GET and DELETE on /mcp now return 405 Method Not Allowed (with Allow: POST) for 2026-07-28 requests, as the transport spec asks for traffic aimed at the removed GET stream and sessions. The check runs before any session is created, subscribed or terminated. Since GET has no body, the era comes from the MCP-Protocol-Version header. New tests cover both methods, including that a modern DELETE leaves a legacy session untouched.

Comment thread src/routes/mcp.ts Outdated
} else {
reply.code(202)
session = await createSSESession()
reply.header('Mcp-Session-Id', session.id)

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.

When a task asks the client for more information, the server does not check whether the client supports that interaction.
For example, a task can request elicitation, while the client only declares support for tasks. The task is still stored with an elicitation request that the client may not understand or answer, so it can remain stuck until timeout.
Please reuse the capability checks from the direct multi-round-trip path before storing inputRequests, and reject or fail the task when the required capability is missing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f498df5. This thread is anchored on routes/mcp.ts, but the fix is in src/modern/handlers.ts. The capability check from the direct MRTR path is now a shared missingInputCapabilities() helper, and the task path calls it before moving to input_required. If a capability is missing, the task fails right away with the same missing-required-client-capability error instead of parking an unanswerable request. Test: a task asking for elicitation from a client that only declares the tasks extension fails and never reports input_required.

Comment thread src/modern/handlers.ts Outdated
})
if (parked) taskWaiters?.notify(parked)

const responses = await taskInputs.wait(record.taskId, AbortSignal.timeout(ttl))

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.

Each task input round gets the full task TTL, even though the TTL should cover the task’s total lifetime.
For example, with a 60-second TTL, a task created at 12:00:00 should expire at 12:01:00. If the client answers the first question at 12:00:50, this code waits another 60 seconds for the next answer, keeping the task alive until around 12:01:50.
Please calculate the remaining time from the original task expiry before each wait, rather than starting a new full TTL for every round.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f498df5. The task records its expiry (createdAt + ttl) once, and each input round waits only for the time left. If none is left, it fails immediately instead of starting a fresh full TTL. The test answers the first round after 300ms and checks that the second wait is at most ttl - 300ms.

- Reject 2026-07-28 GET/DELETE on /mcp with 405 before any legacy
  session is created or terminated
- Check client capabilities before parking a task in input_required,
  sharing the check with the direct MRTR path
- Bound each task input wait by the time left until the task expires
  instead of granting a fresh ttl per round

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@rozzilla rozzilla 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.

  1. Redis can change task results
    The new JSON conversion can turn empty arrays ([]) into objects ({}) and round large numbers. Clients could receive data different from what the tool produced. Preserve the original result when updating task metadata

  2. client input can be lostRedis accepting a message does not guarantee the worker received it. If the worker is disconnected, the server still deletes the saved input and reports success. Retrying cannot recover it, leaving the task stuck. Keep the input until the worker confirms receipt.

  3. Resumed tasks lose their saved state
    A handler can save information in InputRequired.state before asking the client for input. The task path drops that information, so the handler resumes without the context it needs. Pass the saved state back through context.requestState.

- Store task outcomes, input requests and pending responses as JSON
  strings in Redis so the Lua scripts never re-encode them with cjson
- Leave task input in the outbox until the worker has it; the worker
  acknowledges it and reads the outbox if a publication is lost
- Hand InputRequired.state back to the task handler as
  context.requestState on the next round

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mcollina

mcollina commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@rozzilla thanks for the review. All three points are addressed in de26c71.

1. Redis can change task results. The task outcome, input requests and pending responses are now stored in Redis as JSON strings. The Lua scripts pass them through without decoding, so cjson never re-encodes tool or client data. Status, rounds and the other bookkeeping fields are still read in Lua as before. Records written by the old code still decode. New test in test/redis-task-store.test.ts: empty arrays, nested empty arrays, a 16-digit number and 0.1 + 0.2 survive status updates, staged input and completion unchanged.

2. Client input can be lost. tasks/update still publishes, but it no longer acknowledges the outbox. The worker acknowledges it once it has the answers. While waiting, the worker also checks the outbox for its current round every poll interval, so a publication the broker accepted but never delivered is recovered without the client retrying. Late broker copies are deduplicated by delivery id, and leftovers are scoped to their round so they can't reach a later one. New test: the first publication is dropped, tasks/update still succeeds, and the task completes from the outbox.

3. Resumed tasks lose their saved state. InputRequired.state is now handed back as context.requestState on the next task round, as in a direct MRTR retry. It stays on the server, so it isn't sealed. New test: a handler that saves { step: 2, cart: [] } gets it back after tasks/update.

Each new test fails on the previous commit. The full suite, Redis included, passes locally.

mcollina and others added 4 commits October 6, 2026 14:56
- Serve 2026-07-28 over stdio, which has no header layer; the stdio
  transport proves itself with a per-process token HTTP cannot forge
- Reject batches and malformed JSON-RPC messages with -32600 instead of
  accepting a batch as a notification
- Report an unsupported or legacy version before the 2026 required
  fields, on every method including subscriptions/listen
- Reject raw non-ASCII header bytes and non-decimal integer headers
- Treat a tool with a broken x-mcp-header annotation as unknown rather
  than blaming the client's headers
- Validate the listen filter and acknowledge only notification types
  the server's capabilities declare
- Send notifications/cancelled and the graceful empty response, with
  serverInfo, when the server tears down a listen stream, including a
  slow consumer that overflows its buffer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Refuse form elicitation for URL-only clients, and sampling with tools
  or context the client did not declare
- Never forward input requests with unsupported methods or invalid URLs
- Record the issued input keys in sealed state and hand only those
  answers to the handler; reject non-object inputResponses
- Bind sealed state to the OAuth client and issuer, sign it under a
  domain prefix, and refuse empty or short secrets
- Answer an unidentified caller that needs input with a correlated
  error instead of an uncorrelated 500
- Mark MRTR retries ttlMs 0 / private instead of dropping hints
- Keep resultType under the dispatcher's control
- Return resources/read failures as JSON-RPC errors on the modern path
- Enforce outputSchema on structuredContent with a non-mutating validator
- Reject non-object tools/call arguments and invalid caching config
- Stop advertising completions and logging on server/discover
- Stop serializing thrown errors, including InputRequired state, into
  legacy error data

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Resume a task only once every key of its input round is answered,
  and clear outstanding requests while the handler runs
- Give a key the handler asks again a fresh wire key and map its answer
  back, instead of failing the task
- Move a fully answered task to working atomically in both stores
- Resume a state-only InputRequired at once with its state
- Release a parked worker when its task ends, through the outbox poll
  and through legacy tasks/cancel, which now publishes cancellation
- Clear stale status messages and give completed results the same
  envelope as a synchronous call
- Refuse to create tasks an unidentified caller could never reach, cap
  concurrent tasks (taskMaxConcurrent), fall back to synchronous
  execution for optional tasks, and prune the Redis task index
- Stop showing execution.taskSupport to 2026-07-28 clients

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Expose context.signal, an AbortSignal that aborts when the request is
cancelled. On 2026-07-28 a client closing the response stream before
the response is the cancellation signal, which the server must honour.
A task handler gets its own signal, aborted by tasks/cancel on any
instance, so the request that created the task ending does not abort
it. The legacy path never aborts, since older revisions do not treat a
disconnect as cancellation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mcollina

mcollina commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

I ran an adversarial review of this PR against the 2026-07-28 spec in spec/. Five reviewers covered transport, versioning and caching, MRTR and elicitation, subscriptions and server features, and the tasks extension. Every finding was checked against the spec text and, where possible, reproduced. All findings in this PR's scope are fixed in four commits, each with tests. The full suite passes locally, Redis included.

3d44648: transport and subscriptions

  • 2026-07-28 now works over stdio. Previously every modern stdio request failed with -32020, because stdio has no header layer. The stdio transport proves itself with a per-process token an HTTP client cannot forge.
  • A modern batch body is rejected with -32600 instead of being accepted with 202 as a notification and dropped. Malformed messages (id: null, missing jsonrpc or method) are rejected too.
  • An unsupported version is reported before the 2026 required fields, on every method including subscriptions/listen.
  • Raw non-ASCII bytes in Mcp-Name / Mcp-Param-* are rejected, and integer headers must be plain decimal, so 0x2A and 4.2e1 no longer equal 42.
  • A tool with a broken x-mcp-header annotation is reported as unknown rather than as a client header mismatch.
  • The listen filter is validated, and the acknowledgement only agrees to types the server's capabilities declare (listChanged / subscribe).
  • Server teardown of a listen stream, including a slow consumer that overflows, sends notifications/cancelled (MUST in cancellation.md) followed by the graceful empty response with serverInfo (SHOULD in subscriptions.md).

9922d96: MRTR input, tool output, envelopes

  • Form elicitation is refused for URL-only clients, and sampling with tools / includeContext needs the matching sub-capability.
  • Input requests with unsupported methods or invalid URLs are never forwarded.
  • Sealed state records which input keys were issued. Only those answers reach the handler, and non-object inputResponses is -32602.
  • State is bound to the OAuth client and issuer as well as the user, and signed under a domain prefix. Empty or short (< 32 bytes) secrets are rejected at startup. The docs now say the state is signed, not encrypted.
  • An unidentified caller that needs input gets a correlated error instead of a 500 with id: null.
  • MRTR retries carry ttlMs: 0, cacheScope: 'private' instead of no hints.
  • resultType cannot be overridden by a handler.
  • A failed resources/read is a JSON-RPC error on the modern path, never cacheable content.
  • structuredContent is enforced against outputSchema (tools.md: MUST), using a non-mutating validator.
  • Non-object tools/call arguments and invalid caching config are rejected, and completions / logging are no longer advertised on server/discover.
  • Legacy errors no longer serialize thrown values, including InputRequired state, into error.data.

ea4065f: tasks

  • A task resumes only once every key of its input round is answered. Previously answering {a, b} one key at a time failed the task.
  • A key the handler asks for again gets a fresh wire key, mapped back to the handler's key, instead of failing the task.
  • A fully answered task moves to working atomically in both stores.
  • A state-only InputRequired resumes the task at once.
  • A parked worker is released when its task ends, including through legacy tasks/cancel, which now publishes the cancellation.
  • Stale status messages are cleared, and completed results carry the synchronous envelope.
  • No unreachable tasks are created for unidentified callers.
  • New taskMaxConcurrent option (default 1000), with synchronous fallback for optional tasks. The Redis task index is pruned on a schedule.
  • execution.taskSupport is no longer shown to 2026-07-28 clients.

d209416: cancellation

  • Handlers get context.signal. It aborts when a 2026-07-28 client disconnects before the response (cancellation.md: MUST), and on tasks/cancel for task handlers. It never aborts on the legacy path, where a disconnect is not a cancellation.

Behaviour changes worth noting

  • Listen only acknowledges types the server declares, so a server configured with just tools: {} no longer acknowledges toolsListChanged.
  • requestStateSecret must be at least 32 bytes.
  • Invalid caching values throw at startup instead of being clamped.

Not changed: the -32022 supported list still includes legacy versions, because versioning.md's own example does that, and it is how a dual-era client learns it can fall back to initialize.

Already on main, tracked separately: #207 (resource_metadata URL with a path), #208 (audience validation opt-in), #209 (Origin validation off by default), #210 (prompts/get errors as success), #211 (validateJsonSchemaInputs mutates arguments), plus the existing #179 (template reads) and #182 (scope on the first 401).

@rozzilla rozzilla 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

- Compare Mcp-Method literally: only Mcp-Name and Mcp-Param-* may use
  the Base64 sentinel, so a gateway cannot be bypassed with an encoded
  method
- Abort context.signal only when the connection closes while the
  request is still being handled; in-process injection closes before
  writableFinished, which made every stdio and mcpClient request look
  cancelled
- Enforce outputSchema only from 2025-06-18, validate the JSON the
  client receives rather than the in-memory value, and refuse an
  unsupported $schema dialect at registration

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
mcollina and others added 2 commits October 14, 2026 09:58
…ly streaming after the seventh review

- requestStateMaxAnswerBytes caps answers carried in requestState (default 64 KiB)
- taskMaxInputRounds makes the task round limit configurable (default 32);
  state-only rounds wait the poll interval
- accepted elicitation answers are checked against the requested schema;
  URL-mode accepts must carry no content
- responses only stream early once -32021 can no longer occur; tools can
  declare inputCapabilities to keep live progress
- tasks/update only stores checked keys and the store rejects stale rounds
- tool-call hook reports task-ended when a task's final status contradicts
  its last round
- stdio waits for drain; overflowing listen streams end without a graceful
  completion; broken outputSchema is refused at registration
- server/discover with answers or state is not cacheable; Accept q=0 honored
- export HandlerContext, TaskUpdateOptions and ToolDefinitionExtras

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Resolve conflicts with tasks/list pagination (#239), session owner binding
and templated resource reads (#245), Streamable HTTP sessions without SSE
(#242) and JSON-RPC body-parser errors (#230):

- the Redis task store keeps its v2 keyspace for the per-owner indexes, and
  `list` takes an `era` filter so legacy pages are filled before paging
- session owner checks skip modern requests, which carry no session
- without SSE, a legacy GET answers 405 with `Allow: POST, DELETE`
- main's new session tests use the latest legacy revision

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mcollina

Copy link
Copy Markdown
Member Author

Seventh review round

Another adversarial pass against spec/. All 14 in-scope findings are fixed in 508c2b0, then origin/main was merged in 35cfe60. npm run ci passes: 996/996.

Findings that were already on main are filed as #261–#271, plus a comment on #179. A security finding on main was reported privately as a draft advisory and is not fixed here.

Multi round-trip requests

  • Carried answers are capped by the new requestStateMaxAnswerBytes option (64 KiB of JSON by default; 0 turns carrying off). Past the cap, the oldest answers are dropped.
  • Elicitation answers are validated. An accepted form answer must match its requestedSchema, and an accepted URL-mode answer must carry no content. Anything else gets -32602, both on retries and on tasks/update. Declined and cancelled answers aren't checked, and the README example now checks action first.

Tasks

  • Round limit: the new taskMaxInputRounds option sets it (default 32; it was a fixed 8). A round that carries only state now waits the poll interval instead of re-running at once.
  • tasks/update only stores the keys it validated. The store rejects input for a round the task has already left, which fixes both the validation race and the Redis failure on a malformed key.
  • Hook outcome: onToolCallComplete reports a new task-ended outcome when a task's final status (failed or cancelled) contradicts its last round.
  • Docs: the isError split between the two revisions is kept and documented.

Streaming and transport

  • -32021 and early streaming: a response opens its stream early only once -32021 can no longer occur, following the spec.
    • Clients that declare few capabilities now get progress together with the result, not live.
    • A tool can declare the new inputCapabilities to keep live progress; {} means it never asks for input, and the field is never listed.
    • If a tool asks for more than it declared after the stream has opened, the client gets -32603.
  • stdio: waits for stdout to drain, so the 1 MB per-stream limit applies there too.
  • Listen overflow: an overflowing subscriptions/listen stream ends without the graceful-close response, so the client knows to resync.
  • Broadcasts: fixed an unhandled rejection in the broadcast subscriber that the last merge introduced.
  • Accept: text/event-stream;q=0 is now honored.

Other

  • Output schemas: a broken outputSchema is rejected when the tool is registered.
  • server/discover: a request that carries answers or requestState is no longer cacheable. The supportedVersions list is kept as it is and documented.
  • Exports: HandlerContext, ToolDefinitionExtras and TaskUpdateOptions are now exported.

Merge with main

Tests

  • New tests: 19 for this round in test/spec-2026-07-28.test.ts, 2 for Redis, and 2 for the era filter.
  • Updated tests: four existing tests that asserted the old behavior.

🤖 Generated with Claude Code

@rozzilla rozzilla 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.

we should include it in the next semver major.
Redis deployments now require requestStateSecret, but some consumers does not pass one. Production startup will fail when mcpPlugin receives its Redis configuration.

mcollina and others added 2 commits October 15, 2026 10:38
Resolve conflicts with origin/main's 24 new commits:

- tasks keep the branch's principal ownership (user, client and normalized
  issuer) instead of a subject/issuer pair (#296); legacy tasks get leases
  through the branch's renewLease/expireStaleLease API (#292)
- per-caller retention caps with evict-finished-first (#289) are ported onto
  the v2 Redis keyspace and encoding; the create script prunes stale index
  entries and stamps the first lease with the Redis clock
- the modern path remaps -32002 to -32602 (#278), refuses unsafe-integer ids
  (#290), and keeps its own handling of non-message bodies (#286)
- stdio uses stdio-trust instead of stdio-marker (#282, #275, #290) and
  aborts in-flight requests when stdin closes
- tool argument limits keep refusal semantics (#276); broadcast delivery to
  legacy SSE sessions is not limited to resource updates (#294)
- main's new tests use the latest legacy revision, v2 keys and the branch's
  lease API

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s after the eighth review

- keep a leading BOM when decoding Base64 header values, so Mcp-Name and
  Mcp-Param-* cannot differ from the body
- compile elicitation schemas when the handler asks (ignoring annotations,
  $schema and $id), failing with -32603 if they cannot compile; answer
  validators live in a bounded cache
- requestStateMaxAnswerBytes fails the request instead of dropping answers
- subscriptions/listen streams end at token expiry or sseMaxConnectionMs
- request streams honour write() back-pressure, so stdio cannot buffer
  progress without bound
- rounds that only carry state no longer count toward taskMaxInputRounds
- validate inputCapabilities members; refuse a __proto__ input key
- type extensions on capabilities and HandlerContext.clientCapabilities, and
  strip them from the handshake revisions
- README: progress needs inputCapabilities, rolling deploys, listen limits,
  TaskStore list signature

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mcollina

Copy link
Copy Markdown
Member Author

Eighth review round

Another adversarial pass against spec/, plus an audit of the previous merge, which found no functional defects in it. origin/main (24 new commits) is merged in d81896a, and the fixes are in abb516d. npm run ci passes 1111/1111.

Four findings that are already on main are filed as #302–#305.

Merge with main

Fixes

  • Header mirroring: a leading BOM in a Base64 header value is no longer dropped, so Mcp-Name / Mcp-Param-* cannot name something different from the body.
  • Elicitation answers:
    • The schema is compiled when the handler asks, ignoring annotations, $schema and $id, so draft-07 schemas validate.
    • A schema that cannot compile fails with -32603 instead of leaving answers unchecked.
    • Compiled validators live in a bounded cache, so per-request schemas no longer grow memory.
  • Carried answers: past requestStateMaxAnswerBytes, the request fails with -32603 instead of dropping older answers silently.
  • subscriptions/listen: streams end gracefully when the token that opened them expires, or after sseMaxConnectionMs.
  • Request streams: they honour write() back-pressure, so stdio no longer buffers progress and log messages without bound.
  • stdio shutdown: closing stdin cancels requests still running.
  • Tasks:
    • Rounds that only carry state no longer count toward taskMaxInputRounds; the task's ttl bounds them instead.
    • When a modern task cannot be created at the cap, a tool that requires a task gets an error and an optional one runs synchronously.
  • Smaller fixes:
    • inputCapabilities members are validated at registration.
    • An input request keyed __proto__ is refused.
    • extensions is typed on the capabilities option and on HandlerContext.clientCapabilities, and is stripped from the handshake revisions.
  • README:
    • live progress needs inputCapabilities;
    • how rolling deploys interact with the v2 keyspace;
    • limits on anonymous listeners;
    • the new TaskStore.list signature.

Tests

  • Merge: main's new tests are adapted to this branch's protocol versions, v2 keys and lease API. Lease-loss tests now simulate a crash rather than a graceful close, which here drains tasks.
  • New tests: a round 8 block in test/spec-2026-07-28.test.ts, plus unit tests for the answer validator, request-stream back-pressure, lease end notification and Redis create pruning.

🤖 Generated with Claude Code

…ests after the ninth review

- Bind legacy sessions to the resolveAuthorizationContext identity
- Count the task retention cap per user (quotaKey) in memory and Redis
- Flush queued notifications before a listen deadline closes the stream
- Digest MRTR requests before the handler runs; add InputRequired({ carry })
- Accept enum/union x-mcp-header types; throw on invalid annotations
- Answer header/body version mismatch with HeaderMismatch
- Validate answer formats with ajv-formats; keep name-keyed schema maps
- Fail modern task creation at the task cap with -32600; legacy uses -32029
- Refcount unsubscribe per URI; abort modern requests on shutdown
- Accept an array of requestStateSecret values for rotation
- Validate subscription and SSE limit options; refuse fractional progress tokens

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mcollina

Copy link
Copy Markdown
Member Author

Round 9 review: fixed in d6df9f2

npm run ci passes 1134/1134. Each new test fails without the fix.

Fixed in this PR

  • Legacy sessions are now bound to the resolveAuthorizationContext identity (not only the token sub) for GET, POST and DELETE.
  • The task retention cap counts per user (quotaKey = user + issuer) instead of per OAuth client, in the memory and Redis stores. Redis gets its own per-user index.
  • A subscriptions/listen stream that hits its deadline now flushes queued notifications before the graceful close.
  • The MRTR request digest is taken before the handler runs, so handlers that trim or default arguments no longer break their own retry.
  • x-mcp-header accepts TypeBox literal unions and enums. mcpAddTool throws on an invalid annotation instead of dropping the tool.
  • A header/body protocol-version mismatch answers HeaderMismatch (-32020).
  • Carried answers over the budget answer -32602 with a warn log, not -32603.
  • Answer schemas keep name-keyed maps (dependentRequired, $defs, …). email, uri, date and date-time formats are validated through ajv-formats, which is now a direct dependency.
  • Legacy task limits answer -32029.
  • Subscription and SSE limit options are validated at registration.
  • Fractional progressToken values are refused.

Design changes

  • requestStateSecret with redis is a startup warning instead of an error.
  • initialize always takes the legacy path.
  • New InputRequired({ carry }) option, which picks the earlier answers a retry keeps.
  • Modern task creation at the task cap fails with -32600, and onToolCallComplete gets a new task-limit outcome.
  • Unsubscribe handlers are reference-counted per URI.
  • Shutdown aborts the context.signal of in-flight modern requests.
  • requestStateSecret accepts an array for rotation.

Also on main, filed separately: #316, #317, #318, #319, #320.

This branch has not been merged with the latest main (#315, #321) yet.

🤖 Generated with Claude Code

…es after the tenth review

- stdio answers invalid id-less messages with -32600 and a null id
- stdio streams progress and logs while the handler runs
- stdio stop() ends listen streams gracefully
- Flush held notifications before errors that keep HTTP 200
- Answer a legacy SSE GET whose setup fails instead of hanging
- Compute the listen deadline before subscribe handlers; refuse expired tokens with 401
- Refuse requestedSchema values JSON cannot carry
- Evict the creating client's own finished tasks first at the per-user cap
- Move Redis task keys to the v3 keyspace
- Document requestState contents, resolver scope and per-instance listen limits

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mcollina

Copy link
Copy Markdown
Member Author

Round 10 review: fixed in 5e3f41b

npm run ci passes 1144/1144. Each new test fails without its fix.

The MRTR and tasks reviews found no correctness bugs. Running main's own test suite against this branch showed no unintended change in legacy behaviour.

Fixed in this PR

  • stdio answers a message without an id that is not a valid request (such as {"foo":1}) with -32600 and id: null, as HTTP and main do. It used to write nothing.
  • stdio streams progress and log notifications while the handler runs. They were held until the response unless the client declared every input capability. That gate only exists to keep a possible -32021 on HTTP 400, which stdio does not have.
  • stdio stop() (SIGINT/SIGTERM) ends open listen streams with notifications/cancelled and the final empty result, instead of cutting them off.
  • A legacy SSE GET whose setup throws is answered with a 500. It used to hang on a hijacked reply. The session is now resolved before hijack().
  • The listen deadline is computed before the subscribe handlers run, so an expiresAt that is not a Date cannot leak the per-URI unsubscribe count.
  • A requestedSchema containing a non-finite number is refused when the handler asks, since JSON would send the client null in its place.

Design changes

  • A notification held back before an error that still goes out as HTTP 200 (-32603, -32602) is now delivered. Held frames are dropped only for errors that need a 4xx status.
  • At the per-user task cap, the creating client's own finished tasks are evicted before the uncollected results of the user's other OAuth clients. This applies to the memory and Redis stores.
  • A subscriptions/listen whose expiresAt has already passed is refused with 401 and WWW-Authenticate: Bearer error="invalid_token", instead of being acknowledged and closed at once.
  • Redis task keys moved to the mcp:task:v3: keyspace, so records written before the per-user quota index are not miscounted.
  • README updates:
    • what a client can decode from requestState;
    • the resolver runs on the legacy GET and DELETE too and must not throw for anonymous requests (the example is updated);
    • listen limits are per instance;
    • revoked tokens and sseMaxConnectionMs;
    • decline/cancel content is shape-checked only.

Also on main, filed separately: #327, #328, #329, #330.

This branch is still not merged with the latest main.

🤖 Generated with Claude Code

…er the eleventh review

- Keep -32021 on stdio after progress has streamed
- Run every option check before opening a Redis connection
- stdio answers invalid ids with a null id and refuses ids still in flight
- Count listen URI holders before subscribe handlers run
- Cap 2026-07-28 request ids at 256 characters
- Use the standard challenge on the listen 401, none without OAuth
- Stop polling a cancelled stdio tasks/result; never register a waiter for an aborted signal
- Evict the creating client's own finished tasks first when the memory store is full
- Apply carry and JSON state between task rounds
- Refuse input requests with non-finite numbers
- Report task-limit and task-ended for legacy tasks
- Drain tasks before ending listen streams; stop parked tasks at shutdown; let local tasks/result waiters get the drained outcome
- Remove the unused session-auth prehandler
- Document secret rotation order, extra form fields and the id limit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mcollina

Copy link
Copy Markdown
Member Author

Round 11 review: fixed in 86a131c

npm run ci passes 1148/1148. Each new test fails without its fix.

Fixed in this PR

  • stdio
    • A missing client capability stays -32021 after progress has streamed. This was a regression from the last round: the "can't send 400 any more" rewrite now applies to HTTP only.
    • An invalid id (false, {}) is answered with id: null.
    • A request whose id is still in flight is refused with -32600.
    • A tasks/result cancelled with notifications/cancelled stops polling.
  • Option checks: every option check runs before the Redis connection opens. A refused option used to leave sockets open, which kept the process alive.
  • Listen streams
    • URI holders are counted before the subscribe handlers run. A stream ending while another stream's handler was still running could otherwise stop the watcher that stream relied on.
    • 2026-07-28 request ids are capped at 256 characters. A listen id is repeated in every notification frame, so an unbounded id made each broadcast as expensive as the client chose.
    • The listen 401 for an expired token uses the standard challenge (resource_metadata, scope), and sends none when the plugin isn't running OAuth.
  • Tasks
    • TaskWaiters.wait no longer registers a waiter for a signal that has already aborted (tasks/result leaves a waiter behind forever when its client has already disconnected #333).
    • A full memory store evicts the creating client's own finished tasks first, as the per-user cap already did.
    • Task rounds apply carry and pass state through JSON, so a task handler sees the same inputs a sync retry would.
    • Legacy tasks report task-limit and task-ended to onToolCallComplete, as 2026-07-28 tasks do.
  • Input requests: an input request containing Infinity or NaN (a sampling maxTokens, say) is the handler's error, instead of being sent to the client as null.

Design changes

  • Shutdown
    • Running tasks drain before listen streams get their graceful end, so changes made during the drain still reach those streams.
    • A task parked in input_required is failed at once, since no tasks/update can reach it while the server closes.
    • A blocked tasks/result for a task running on this instance waits for the drain and returns the task's outcome.
  • Removed: src/auth/session-auth-prehandler.ts, which was never wired up or exported.
  • README
    • Secret rotation now takes three deploys ([old, new], then [new, old], then [new]), so no retry fails mid-rollout.
    • A warning that accepted form answers can carry extra properties.
    • The request id limit.
  • Kept as is: -32022 still lists the legacy revisions in supported, matching the spec's example.

Also on main, filed separately: #333, #334, #335, #336, #337.

This branch is still not merged with the latest main.

🤖 Generated with Claude Code

…rs after the twelfth review

- stdio: requests already read when stdin closes get up to a second to
  finish and are still answered; only client-cancelled requests go unanswered
- subscriptions/listen: a subscribe waits for a pending unsubscribe of the
  same URI, so a fast reconnect keeps its watcher
- MRTR answers: refuse numbers JSON cannot carry, validate sampling content
  blocks and require file:// roots
- shutdown drain notifies local tasks/result waiters of the recorded outcome
- legacy requests carrying Mcp-Method / Mcp-Name must match the body (-32020)
- clientCapabilities.extensions / experimental members must be objects
- legacy sessions are bound to the OAuth client as well as user and issuer
  (SessionMetadata.ownerClient, persisted in Redis)
- signing keys are fetched as a whole set by a small cache, replacing get-jwks
- README updates

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mcollina

Copy link
Copy Markdown
Member Author

Twelfth review round, pushed in f318913.

Fixed on this branch

  • stdio: requests already read when stdin closes get up to a second to finish and are still answered (printf '<req>\n' | server prints its response again). Only requests the client cancelled go unanswered.
  • subscriptions/listen: a subscribe now waits for a pending async unsubscribe of the same URI, so a quick reconnect keeps its watcher.
  • MRTR answers:
    • numbers JSON cannot carry (e.g. -1e400) are refused;
    • sampling answers need well-formed content blocks;
    • roots answers need file:// URIs.
  • Shutdown drain: local tasks/result waiters now hear the outcome it records, so they answer before close() resolves.
  • Mirrored headers: a legacy request (e.g. initialize) that sends Mcp-Method or Mcp-Name must match its body, or it gets -32020 / HTTP 400.
  • clientCapabilities.extensions / experimental: each member must be an object, so null or "yes" no longer opts into tasks.
  • Legacy sessions: they are now bound to the OAuth client as well as user and issuer (SessionMetadata.ownerClient, persisted in Redis). This matches how tasks and MRTR state already bind the client.
  • Signing keys: they are fetched as a whole set by a small cache (src/auth/jwks.ts), and get-jwks is dropped as a dependency.
  • README: updated, including the two claims about initialize headers.

Also present on main, filed separately: #338, #339, #340, #341, #342, #343, #344.

npm run ci: 1163/1163 passing. Each new test fails without its fix.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

semver-major Breaking change; requires a major release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants