Skip to content

feat(server): BackchannelCompat; Server.buildContext outbound via ctx.mcpReq.send#2059

Draft
felixweinberger wants to merge 3 commits into
fweinberger/r3-handlehttpfrom
fweinberger/r4-backchannel
Draft

feat(server): BackchannelCompat; Server.buildContext outbound via ctx.mcpReq.send#2059
felixweinberger wants to merge 3 commits into
fweinberger/r3-handlehttpfrom
fweinberger/r4-backchannel

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor


Adds BackchannelCompat — the legacy server→client request channel for handleHttp (writes elicitation/sampling onto the open POST's SSE stream and correlates the client's POSTed-back response). Server.buildContext factors elicitInput/requestSampling through _createMessageVia/_elicitInputVia so the instance methods and the ctx.mcpReq.send path share the same validation/capability checks.

Motivation and Context

Completes handleHttp's legacy-client story: a pre-2026-06 client connecting to a handleHttp server can answer elicitation/sampling. The _*Via factor keeps validation in one place regardless of which path (createMessage() vs ctx.mcpReq.requestSampling()) is used.

How Has This Been Tested?

Dual conformance both 40/40, client conformance 289/289. New backchannelCompat.test.ts + a real elicitation round-trip via tools/call test_elicitation.

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

R4 of decomposition; depends on R0–R3. _*Via reads the instance _clientCapabilities (same as main); F1 makes it per-request via _meta. Review guide: https://gist.github.com/felixweinberger/5a48e0f14d5aced39ed6a91b61940711.


@felixweinberger felixweinberger added the v2-stateless 2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless) label May 12, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 12, 2026

⚠️ No Changeset found

Latest commit: 716039a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2059

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2059

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2059

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2059

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2059

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2059

commit: 716039a

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

1 similar comment
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/server/src/server/server.ts
@felixweinberger felixweinberger force-pushed the fweinberger/r3-handlehttp branch from 7758dfc to b0d286d Compare May 12, 2026 14:38
@felixweinberger felixweinberger force-pushed the fweinberger/r4-backchannel branch from 3eebc7f to 2358111 Compare May 12, 2026 14:38
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Thanks — 2358111 resolves the createMessage cap-check regression I flagged; what's left from this pass is two non-blocking nits. Not auto-approving only because this adds a new public class (BackchannelCompat) with stateful request correlation and transport-layer wiring, which is worth a maintainer's design sign-off.

Extended reasoning...

Overview

R4 of the handleHttp decomposition series. Adds BackchannelCompat (new public export, ~120 lines) — a per-session {requestId → resolver} map that lets a tool handler's ctx.mcpReq.send write elicitation/sampling requests onto the open POST's SSE stream and await the client's POSTed-back response. shttpHandler is wired to route inbound responses through it and to call closeSession on session close (guarded by a module-level WeakMap<SessionCompat, WeakSet<BackchannelCompat>> to avoid listener accumulation). Server.buildContext now routes elicitInput/requestSampling/log through ctx.mcpReq.send/notify via new _createMessageVia/_elicitInputVia shared bodies, so the instance methods and the per-request path share validation. Conformance harness updated; new vitest suite covers resolve/reject/timeout/abort/closed-stream/per-session isolation.

Change since last review

My prior 🔴 (unconditional base-sampling cap check breaking createMessage() v1 parity) was addressed in 2358111_createMessageVia now only enforces the sampling.tools sub-capability inline, with a comment explaining the base check stays gated on enforceStrictCapabilities. That restores the pre-PR contract for the public instance method. The two findings from this pass (missing reason on notifications/cancelled, and onprogress/resetTimeoutOnProgress/maxTotalTimeout silently dropped) are both parity/diagnostics nits on a path that previously rejected outright with NotConnected, so neither is a regression.

Security risks

None identified. No auth, crypto, or permission logic touched. Response correlation is keyed by (sessionId, numericId) with an explicit typeof response.id !== 'number' guard, so a string "0" cannot claim numeric pending id 0. The backchannel is only attached when both session and backchannel are configured and sessionId is defined, so stateless requests can't reach another session's resolver map.

Level of scrutiny

Medium-high. This is production transport-layer code with new public API surface and exactly the async-lifecycle shape (abort listeners, deferred timers, cleanup ordering, close-path teardown) the repo's Recurring Catches section flags. The implementation looks careful — cleanup() removes the abort listener and clears the timer on every settle path, pre-aborted signals short-circuit before allocating an id, writeSSE returning false rejects immediately rather than hanging — and the test file exercises each of those branches. But "every new export is intentional" and "burden of proof is on addition" are first-order principles here, and the module-level wiredBackchannels WeakMap plus the documented _clientCapabilities singleton caveat are design choices a maintainer should ratify.

Other factors

Dual conformance is reported 40/40 on both targets and the expected-failures baseline is now empty, which is good evidence the legacy-client path works end-to-end. No changeset is included; given a new public export this is likely intentional for the decomposition series but worth confirming. The PR is part of a stacked sequence (depends on R0–R3, F1 follow-up planned for per-request caps), so a human reviewer with that series context is better placed to approve than I am.

Comment on lines +56 to +59
const onAbort = () => {
// Tell the client to stop processing, then reject locally.
writeSSE({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id } });
settle.reject(opts!.signal!.reason instanceof Error ? opts!.signal!.reason : new Error(String(opts!.signal!.reason)));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: onAbort sends notifications/cancelled with only { requestId: id }, dropping the abort reason even though opts!.signal!.reason is read on the very next line to build the local rejection. The connected-transport path (StreamDriver.request, streamDriver.ts:187-188) includes reason: String(reason) on the same notification — adding reason: String(opts!.signal!.reason) here would give the client the same diagnostic context (e.g. "Client closed SSE stream") and keep the two env.send providers in parity. Spec-optional, so non-blocking.

Extended reasoning...

What the gap is

BackchannelCompat.makeEnvSend's onAbort handler (backchannelCompat.ts:56-59) writes:

writeSSE({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id } });
settle.reject(opts!.signal!.reason instanceof Error ? opts!.signal!.reason : new Error(String(opts!.signal!.reason)));

The wire notification carries only requestId. The abort reason is in scope — it's dereferenced on the immediately following line to construct the local rejection — but it never reaches the client.

The sibling path includes it

BackchannelCompat is one of two env.send providers. The other, StreamDriver.request (packages/core/src/shared/streamDriver.ts:184-191), handles the same abort-→-cancel case for the connected-transport path and sends:

{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: messageId, reason: String(reason) } }

So a server-initiated request cancelled via StreamDriver tells the client why; the same request cancelled via BackchannelCompat does not. Same protocol message, same semantic event, two different payloads depending on which transport adapter the server is mounted through.

Why nothing prevents it

CancelledNotification.params.reason is z.string().optional() in the spec schema, so the message validates either way. There's no shared helper that builds this notification — each path constructs the literal independently — so nothing forces them to agree on shape.

Step-by-step

  1. Server is mounted via handleHttp(mcp, { session, backchannel }); a tool handler calls ctx.mcpReq.elicitInput(params, { signal }).
  2. ctx.mcpReq.send resolves to backchannel.makeEnvSend(sessionId, writeSSE). The elicitation request is written to the POST's SSE stream with id 0; signal.addEventListener('abort', onAbort) is registered.
  3. The inbound request that spawned this handler is cancelled (e.g. the client disconnects and shttpHandler's cancel() runs ctrl.abort(new Error('Client closed SSE stream')), which propagates to the handler's ctx.mcpReq.signal if the handler wired it through).
  4. onAbort fires. It writes { method: 'notifications/cancelled', params: { requestId: 0 } } to the SSE stream — no reason field. Then it rejects the local promise with Error('Client closed SSE stream').
  5. The client receives a bare cancellation. It knows request 0 was cancelled but not whether the server timed out, the user aborted, or the stream broke. Had the same flow gone through StreamDriver, the client would have received reason: "Error: Client closed SSE stream".

Impact

Not a protocol violation — reason is optional — so this is a consistency/diagnostics nit, not a correctness bug. The client loses human-readable cancel context on the handleHttp backchannel path only, and the two env.send implementations diverge on wire shape for no stated reason.

Fix

One-token addition:

writeSSE({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id, reason: String(opts!.signal!.reason) } });

This matches StreamDriver exactly and reuses the value already being read on the next line.

Comment on lines +79 to +88
const timeoutMs = opts?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
timer = setTimeout(
() => settle.reject(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: timeoutMs })),
timeoutMs
);

opts?.signal?.addEventListener('abort', onAbort, { once: true });

const wire: JSONRPCRequest = { jsonrpc: '2.0', id, method: req.method, params: req.params };
if (!writeSSE(wire)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: makeEnvSend reads only opts.signal and opts.timeout, so onprogress / resetTimeoutOnProgress / maxTotalTimeout are silently dropped on this path — the same ctx.mcpReq.requestSampling(params, { onprogress, resetTimeoutOnProgress: true }) call that gets progress callbacks and timeout extension via StreamDriver.request on the connect(transport) path gets neither here. Wiring progress fully would require routing inbound notifications/progress through the backchannel (out of scope for this shim), so consider just adding a sentence to the JSDoc next to the existing "not persisted to EventStore" caveat noting these options aren't honoured on the legacy backchannel path.

Extended reasoning...

What the gap is

RequestOptions exposes onprogress, resetTimeoutOnProgress, and maxTotalTimeout (context.ts:70,89,96), and the env.send contract type is (Request, RequestOptions?) => Promise<Result> (context.ts:250), so callers see the full RequestOptions surface on ctx.mcpReq.send. There are two env.send providers:

  • StreamDriver.request (the connect(transport) path) honours all three: when opts.onprogress is set it injects params._meta.progressToken and registers a progress handler (streamDriver.ts:172-178), and it wires maxTotalTimeout / resetTimeoutOnProgress into the timeout machinery (streamDriver.ts:217-219).
  • BackchannelCompat.makeEnvSend (this PR) reads only opts.signal (lines 45, 59, 65, 85) and opts.timeout (line 79). The wire request at line 87 is built as {jsonrpc, id, method, params: req.params} verbatim — no _meta.progressToken is added — and no progress-handler map exists.

Why the dispatcher doesn't cover this

One might expect the shared ctx.mcpReq.send wrapper in dispatcher.ts to handle progress, but it doesn't: the wrapper at dispatcher.ts:168-185 just forwards {...options, signal} to env.send. Setting _meta.progressToken and registering progress handlers is the env.send sink's responsibility — StreamDriver.request does it, makeEnvSend does not. So the asymmetry is real and lives at exactly this layer.

Step-by-step proof

  1. A tool handler running under handleHttp + {session, backchannel} calls ctx.mcpReq.requestSampling(params, { onprogress: cb, resetTimeoutOnProgress: true }) for a long-running LLM sampling call.
  2. Server.buildContext_createMessageVia(ctx.mcpReq.send, params, sendOpts(options)) (server.ts:170) — sendOpts spreads the caller's options, so onprogress and resetTimeoutOnProgress are still present.
  3. ctx.mcpReq.send (dispatcher.ts) calls env.send(r, {...options, signal}) — still present.
  4. env.send is backchannel.makeEnvSend(sessionId, writeSSE) (shttpHandler.ts:391). Inside makeEnvSend:
    • opts.onprogress is never read → no _meta.progressToken on the wire request → the client never knows to send notifications/progress.
    • Even if the client did send progress, shttpHandler has no path that routes inbound notifications/progress to the backchannel.
    • opts.resetTimeoutOnProgress / opts.maxTotalTimeout are never read → the single setTimeout at line 80 fires at opts.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC regardless.
  5. Result: cb is never invoked, and the request times out at the default 60s even though the same call on the connect(transport) path would have extended on each progress notification. No error is raised about the dropped options.

Impact and why it's a nit

The behavioural divergence is real but narrow: server→client sampling-with-progress over the legacy SSE backchannel is an uncommon path, and before this PR ctx.mcpReq.send on handleHttp rejected with NotConnected entirely, so this is strictly an improvement. The class JSDoc already frames BackchannelCompat as "one removable file once MRTR (SEP-2322) is the protocol floor", and implementing progress properly would require shttpHandler to route inbound notifications/progress POSTs to a per-backchannel handler map plus timeout-reset machinery — substantial scope creep for a temporary compat shim, and counter to the repo's Minimalism principle.

Suggested fix

Add one sentence to the makeEnvSend JSDoc, alongside the existing "Backchannel writes are not persisted to the EventStore…" caveat, e.g.:

RequestOptions.onprogress, resetTimeoutOnProgress, and maxTotalTimeout are not honoured on this path; use a fixed timeout for long-running requests.

That documents the limitation for users of this public class without expanding the shim's scope.

@felixweinberger felixweinberger force-pushed the fweinberger/r3-handlehttp branch from b0d286d to f15bcb8 Compare May 12, 2026 15:56
@felixweinberger felixweinberger force-pushed the fweinberger/r4-backchannel branch from 2358111 to 8493aac Compare May 12, 2026 15:56
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +51 to +63

test('per-session correlation: same id on different sessions does not collide', async () => {
const sendA = bc.makeEnvSend('A', writeSSE);
const sendB = bc.makeEnvSend('B', writeSSE);
const pA = sendA({ method: 'x' });
const pB = sendB({ method: 'x' });
const wA = written[0] as JSONRPCRequest;
const wB = written[1] as JSONRPCRequest;

bc.handleResponse('B', { jsonrpc: '2.0', id: wB.id, result: { from: 'B' } });
await expect(pB).resolves.toEqual({ from: 'B' });
bc.handleResponse('A', { jsonrpc: '2.0', id: wA.id, result: { from: 'A' } });
await expect(pA).resolves.toEqual({ from: 'A' });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: the test name says "same id on different sessions does not collide", but _nextId is an instance-wide counter (backchannelCompat.ts:27), so sendA mints id=0 and sendB mints id=1 — the ids are never the same. If _pending were flattened to a single Map<number, settle> with no session dimension this test would still pass, so it doesn't actually cover per-session keying. Adding expect(bc.handleResponse('A', { jsonrpc: '2.0', id: wB.id, result: {} })).toBe(false) (response for B's id arriving on session A must not be claimed) would make the test fail if session keying were ever removed — or just rename the test to reflect what it checks.

Extended reasoning...

What the test claims vs. what it does

The test at backchannelCompat.test.ts:51-63 is titled 'per-session correlation: same id on different sessions does not collide'. The implication is that two pending requests share a numeric request id and the per-session map keeps them apart. But BackchannelCompat._nextId is a single instance-level counter:

private _nextId = 0;
// ...
const id = this._nextId++;

It is incremented inside makeEnvSend's returned closure regardless of which sessionId the closure was bound to. So in the test, sendA({method:'x'}) mints id=0 and sendB({method:'x'}) mints id=1. wA.id !== wB.id — the "same id on different sessions" scenario the title describes never occurs.

Why this is false coverage

The reason _pending is Map<string, Map<number, settle>> rather than Map<number, settle> is so that a response POSTed back on session B cannot resolve a request that session A is awaiting. This test does not exercise that property. Counterfactual: imagine _pending were flattened to a single Map<number, settle> and handleResponse ignored its sessionId argument entirely. Then:

  1. sendA({method:'x'})pending.set(0, settleA); sendB({method:'x'})pending.set(1, settleB).
  2. bc.handleResponse('B', {id: wB.id /* 1 */, result:{from:'B'}}) → finds settleB, resolves pB. ✅
  3. bc.handleResponse('A', {id: wA.id /* 0 */, result:{from:'A'}}) → finds settleA, resolves pA. ✅

Both assertions pass. The earlier test at line 48-49 (handleResponse('unknown', {id:99}) === false) doesn't catch it either since id 99 was never minted. So nothing in this file would fail if the per-session dimension of _pending were removed.

Step-by-step proof on the actual code

  1. bc = new BackchannelCompat()_nextId = 0.
  2. sendA = bc.makeEnvSend('A', writeSSE); sendB = bc.makeEnvSend('B', writeSSE). Both closures capture the same this.
  3. pA = sendA({method:'x'}) → executes const id = this._nextId++id = 0, _nextId = 1. written[0].id === 0.
  4. pB = sendB({method:'x'})id = 1, _nextId = 2. written[1].id === 1.
  5. wA.id = 0, wB.id = 1 — distinct. The test then resolves each by its own id on its own session, which would work with or without session keying.

Impact

Production code is correct — this is purely test-quality. The test gives false confidence that per-session isolation is covered: a future refactor that drops the outer Map<string, …> (or that accidentally looks up across sessions) would not be caught here. Given _nextId is instance-global, the literal scenario in the test name cannot even occur within a single BackchannelCompat instance, so the name is misleading on its face.

Suggested fix

Either rename the test to what it actually verifies (e.g. "responses route to the correct session"), or — better — add one assertion that would fail under a flat map:

// Response for B's id arriving on session A must not be claimed.
expect(bc.handleResponse('A', { jsonrpc: '2.0', id: wB.id, result: {} })).toBe(false);

placed before the handleResponse('B', …) call. With a flat Map<number, settle> this would return true (and resolve pB with the wrong payload), so the test would actually guard the invariant its name advertises.

….mcpReq.send

BackchannelCompat: opt-in 2025-11 server-to-client backchannel for handleHttp. A handler's elicitInput/requestSampling writes onto the open POST's SSE stream and the client's POSTed-back response resolves the await.

Server.buildContext factors elicitInput/requestSampling into _createMessageVia/_elicitInputVia so instance methods and ctx.mcpReq share the same validation and capability check (capability check reads instance _clientCapabilities; per-request flow in F1).
…check (v1 parity; was gated on enforceStrictCapabilities)
@felixweinberger felixweinberger force-pushed the fweinberger/r4-backchannel branch from 8493aac to 716039a Compare May 12, 2026 17:28
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

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

Labels

v2-stateless 2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant