[DO NOT MERGE] RFC reference: request-first SDK architecture#1942
Draft
felixweinberger wants to merge 55 commits intomainfrom
Draft
[DO NOT MERGE] RFC reference: request-first SDK architecture#1942felixweinberger wants to merge 55 commits intomainfrom
felixweinberger wants to merge 55 commits intomainfrom
Conversation
Dispatcher: stateless handler registry with async-generator dispatch(). Yields notifications then a terminal response. No transport, no correlation state, no timers; one instance serves any number of concurrent requests. StreamDriver: runs a Dispatcher over a persistent Transport pipe. Owns the per-connection state that previously lived on Protocol: request-id correlation, timeouts, progress callbacks, cancellation, notification debouncing.
…r + SessionCompat - McpServer (1432 LOC): merged mcp.ts + server.ts, extends Dispatcher<ServerContext> - shttpHandler (398 LOC): request/response SHTTP core, no stream-mapping - sessionCompat (197 LOC): bounded LRU 2025-11 session compat - 97 server tests passing, typecheck clean workspace-wide
…2/clientTransport
…gler port in CF test
…y asserts + request() + reconnect for v1 compat Integration tests 74 fail -> 0. All suites green.
…tx.http All tests green: 1529 (1376 baseline + 153 new). Typecheck clean.
… ctx fields for v1 compat; fix prompts schema, completion handler, tools-list Conformance: 30/30 scenarios (40/40 checks) passing via v1-pattern path. All SDK tests green: 1529/1529. Typecheck clean.
…eanup - experimental.tasks getter, getTask/listTasks/cancelTask methods - callTool handles CreateTaskResult polymorphism - 5 new client tests - lint cleanup across client/server In-repo conformance: server 40/40 + client 289/289. SDK tests 1534/1534.
…(gRPC/protobuf)
Yields {kind: notification|result|error} without JSON-RPC wrapping.
Handlers unchanged. docs/grpc-integration.md shows the adapter shape.
…+ capabilities + resourceTemplate - mcpServer.ts: 1795 -> 933 LOC (core class, handle/connect/buildContext, server->client methods) - serverRegistries.ts: 873 (registerTool/Resource/Prompt + lazy installers, composed via _registries) - serverCapabilities.ts: 130 (assert* functions, method->capability table) - serverLegacy.ts: 105 (v1 overload parsing helpers) - resourceTemplate.ts: 45 All tests/lint/typecheck green.
- Replaced client.ts with clientV2's Dispatcher-based implementation - Ported: applyElicitationDefaults + setRequestHandler validation, listChanged debounce, getSupportedElicitationModes, ClientTasksCapabilityWithRuntime, ClientOptions extends ProtocolOptions - pipeAsClientTransport accepts StreamDriverOptions (tasks config flows through) - Deleted clientV2.ts, renamed test file - Fixed onclose double-fire, task option threading, cancel SdkError type All 1537 tests + lint + typecheck green.
…ts for v1 path compat - packages/sdk/: 33 stub files re-exporting from client/server/node/express at v1 paths - Adds Prompt/Resource/*Result/*Schema exports to types.js path - 6 sdk tests passing, typecheck clean, builds + packs Enables bump-only consumers using @modelcontextprotocol/sdk/* paths.
…StreamableHTTPError, SSEServerTransport alias - Root package renamed sdk-workspace (fixes tarball collision) - packages/server-auth-legacy/ from BC-carve E2 (+142 tests) - StreamableHTTPError deprecated alias in client - sdk auth stubs re-export from server-auth-legacy - SSEServerTransport deprecated alias to NodeStreamableHTTPServerTransport - typed setRequestHandler(schema) overload Bump-only: 5/14 -> 9/14 at zero (browserbase, shortcut, console, mcp-ext-apps-host now 0). 1685 tests, typecheck/build clean.
…; setRequestHandler(ZodSchema) overload on Client Adds types: ./dist/index.d.mts and typesVersions to all packages so consumers using moduleResolution: node (Node10) resolve subpath types. Fixes the StandardSchemaV1 constraint on the setRequestHandler ZodSchema overload.
- New core/shared/context.ts holds BaseContext/ServerContext/ClientContext/ RequestOptions/NotificationOptions/ProtocolOptions/ProgressCallback/ DEFAULT_REQUEST_TIMEOUT_MSEC/mergeCapabilities (extracted from protocol.ts) - protocol.ts: re-exports context.ts + thin Protocol class composing Dispatcher + StreamDriver. v1-signature buildContext preserved; assert* methods are now concrete no-ops. _taskManager/_responseHandlers proxied to driver for test-compat. - streamDriver.ts: add _closed flag so debounced notifications scheduled before close() do not send after it (matches old Protocol behavior) - server/mcp.ts and server/server.ts: now pure re-exports of mcpServer.ts/ compat.ts - protocol.test.ts: 6 toHaveBeenCalledWith assertions accept the relatedRequestId second arg StreamDriver passes; 2 progress-stop-on- terminal tests use ctx.task.store path (matching the (completed) variant); override modifiers added now that Protocol's hooks are concrete. LOC: protocol.ts 1105->249, mcp.ts 1329->5, server.ts 677->7. context.ts +297. 1685/1685 tests green.
…around env.send is not provided by shttpHandler; documented why (re-introduces per-session correlation state) and the two alternatives (connect() path for 2025-11, MRTR for 2026-06+).
- New backchannel2511.ts: per-session {requestId -> resolver} map for the
pre-2026-06 server-to-client request channel (elicitation/sampling over SSE)
- SessionCompat: store negotiated protocolVersion per session
- shttpHandler: route incoming JSON-RPC responses to backchannel; supply
env.send for sessions negotiated < 2026-06-30; register standalone GET
writer with backchannel
…N-Schema emission isZodTypeLike widened to detect _def (v3) as well as _zod (v4). coerceSchema: v4 raw shapes wrap with z.object; v3/mixed shapes get a StandardSchemaWithJSON adapter that synthesizes JSON Schema from _def.typeName. standardSchemaToJsonSchema falls back to z.toJSONSchema for cross-instance zod values. Client.request gains (req, ResultSchema, opts) overload.
…erver for subclassers
ServerRegistries' setToolRequestHandlers/setResourceRequestHandlers/etc were
private and called directly from registerTool/etc. Consumers like shortcut's
CustomMcpServer override (this as any).setToolRequestHandlers on the instance
to install a custom tools/call handler that re-throws BearerAuthError as
McpError. With registries calling its own private method, the override never
fired and the default {isError:true} wrapping swallowed the error.
Fix: route the lazy-install calls through the host (McpServer) via new methods
on RegistriesHost. McpServer's default implementations delegate back to
ServerRegistries. validateToolInput/validateToolOutput/handleAutomaticTaskPolling/
createToolError/executeToolHandler are also exposed as protected delegates so
the override's body can call them via (this as any).X.
Consumer test results after fix:
- shortcut CustomMcpServer.test 9/9 (was 8/1)
…te through shttpHandler - streamableHttp.ts 1038 -> ~290: bind(server) builds shttpHandler with SessionCompat + Backchannel2511 - McpServer.connect detects request-shaped transports (has handleRequest), calls bind() instead of StreamDriver - shttpHandler: TaskManager wiring (processInboundRequest), DNS-rebinding option, EventStore replay, async session callbacks - Node wrapper updated for new shape - All 1685 SDK + 422 integration + conformance 40/40 + 289/289 green ON NEW PATH
…d (DNS rebinding) The gutted _validateDnsRebinding allowed requests with no Host header through. Restore original behavior: when allowedHosts is set, missing Host = reject.
…herHandlesTasks flag McpServer's task-aware dispatch() override + StreamDriver._onrequest both called processInboundRequest. Idempotent for non-task requests but redundant. StreamDriver now skips its own task processing when the dispatcher handles it.
…r custom methods Ports the BC-carve A1b overload to Dispatcher + McpServer + Client overrides. Validates request.params (minus _meta) against any StandardSchemaV1; handler receives the typed parsed params. Adds protected _wrapParamsSchemaHandler so subclass overrides compose uniformly. Also: lint-disable for streamDriver routeResponse default (conditional reassign pattern from 250b6667).
…esolution
jest's resolver follows the require condition, not import. Each entry now has
{types, import, require} where require points at the same .mjs (Node 22+
require(esm) and jest's enhanced-resolve handle this).
…ono/fastify/auth-legacy exports Jest CJS resolver needs an explicit require condition; without it deep-import subpaths fail to resolve in projects using moduleResolution that consults require.
…ver, handleHttp per request)
…iant showing both v1-style and new-direct patterns
…sport (request-shaped) Dual-interface: keeps Transport methods (send/start/onmessage/etc) for back-compat, adds fetch/notify/subscribe (ClientTransport). isPipeTransport prefers ClientTransport when fetch present, so client.connect uses the request-shaped path directly. - Auth dedup: 401/403/upscope into one _authedHttpFetch - ClientFetchOptions.onrequest/.onresponse for inbound elicitation + queued task responses - Client owns TaskManager when no _ct.driver (request-shaped path) - _discoverOrInitialize: only accept discover result if serverInfo present - streamableHttpV2.ts deleted (merged in) Conformance client: 289 -> 312/318. Known gap: elicitation-sep1034-client-defaults (6) — server-sent elicitation request on SSE stream not yet round-tripped (onrequest hook present but POST-back wiring incomplete). Tier-4 follow-up. 1687 SDK + 422 integration + tc/lint clean.
…aped path) Root cause of elicitation-sep1034-client-defaults: conformance server sends server-initiated requests (elicitation) on the standalone GET stream, not the POST response stream. v1 client auto-opened it; the gut removed it. _startStandaloneStream() opens subscribe(), routes inbound requests to _localDispatcher.dispatch, responses to taskManager, notifications to handlers. Conformance client: 312/318 -> 317/317. 1687 SDK + 422 integration.
…utbound, not StreamDriver McpServer and Protocol no longer know about StreamDriver specifically. They hold an OutboundChannel — the minimal contract for sending requests/notifications to the connected peer. StreamDriver implements it (and gains setProtocolVersion/ sendRaw delegates). Request-shaped paths can supply their own. The deprecated transport getter still returns the underlying pipe when present.
…sk processing to dispatch() StreamDriver no longer constructs, owns, or knows about TaskManager. It exposes a generic OutboundInterceptor hook (request/notification/response/close) at the correlation seam; callers wire their TaskManager through it. Inbound task processing (processInboundRequest) moves to where it belongs: the dispatch() override. McpServer already had this; Protocol's inner dispatcher and Client's _localDispatcher gain matching overrides. McpServer/Protocol/Client each construct and own their TaskManager and pass an interceptor to StreamDriver. dispatcherHandlesTasks/tasks/taskManager/ enforceStrictCapabilities options removed from StreamDriverOptions.
McpServer.connect() now takes Transport | (Transport & RequestServerTransport) and uses isRequestServerTransport() instead of probing for a 'bind' property. WebStandard/Node SHTTP server transports implement RequestServerTransport via attach(); the old bind() name is kept as a deprecated alias.
Mirrors McpServer's structure: both extend Dispatcher directly. The task-aware dispatch override moves from the anonymous inner class to Client.dispatch(). setRequestHandler becomes an override; the trivial delegate methods (removeRequestHandler/setNotificationHandler/removeNotificationHandler/ fallbackNotificationHandler) are removed in favor of inherited ones.
…erver.connect drops StreamDriver knowledge McpServer.connect() is now: build AttachOptions, then outbound = transport.attach?.(this, opts) ?? attachPipeTransport(transport, this, opts) No StreamDriver import, no isRequestServerTransport, no shape discrimination beyond 'does it have attach'. attachPipeTransport (in streamDriver.ts) is the back-compat helper that wraps a plain pipe Transport. WebStandard/Node SHTTP transports' attach() build shttpHandler for inbound and call attachPipeTransport(this, server, opts) for outbound (their Transport.send() routes via the standalone GET stream). RequestServerTransport renamed AttachableTransport (deprecated alias kept). Client side not unified in this commit: Client uses ClientTransport (fetch-based, per-request hooks for onprogress/onrequest/onresponse) which is genuinely a different contract from OutboundChannel. _startStandaloneStream stays on Client.
…ChannelTransport
Transports no longer reference Dispatcher. McpServer.connect() sets the
transport's onrequest/onnotification/onresponse callback slots (mirroring v1's
onmessage pattern). The pair is now ChannelTransport (pipe: stdio/WS/InMemory)
and RequestTransport (req/response: Streamable HTTP).
- ChannelTransport: same shape as the old Transport interface; Transport kept as
deprecated alias.
- RequestTransport: { onrequest?, onnotification?, onresponse?, close,
notify? (2025-11 standalone-GET back-compat), request? (same) }.
- isRequestTransport(t) discriminates by 'onrequest' in t (transports declare
the property so it's present before connect()).
- shttpHandler signature: (cb: ShttpCallbacks, opts) — McpServerLike kept as
deprecated alias.
- WebStandard/NodeStreamableHTTPServerTransport drop attach(d)/bind(); keep the
ChannelTransport costume for back-compat.
- AttachableTransport, RequestServerTransport, isRequestServerTransport removed.
- attachPipeTransport remains as the internal helper for the channel path.
… middleware Dispatcher gains `use(mw: DispatchMiddleware)` and McpServer/Client/Protocol gain `useOutbound(mw: OutboundMiddleware)`. TaskManager is no longer a bound host vtable: `attachTo(d, hooks)` registers itself via `d.use()`, installs the `tasks/*` handlers, and returns the OutboundMiddleware the caller registers. McpServer/StreamDriver/Client no longer import TaskManager-specific types; the `_dispatchYielders`/`_dispatchOutboundId` state and the McpServer dispatch override are gone (the per-dispatch sideQueue lives inside the middleware via `TaskContext.sendOnResponseStream`). Renames (no aliases; rebuild-only names): `OutboundChannel`->`Outbound`, `OutboundInterceptor`->`OutboundMiddleware`, `attachPipeTransport`-> `attachChannelTransport`, `isPipeTransport`->`isChannelTransport`, `pipeAsClientTransport`->`channelAsClientTransport`. `DispatchEnv` moved to context.ts and renamed `RequestEnv` (deprecated alias kept) so transport.ts no longer type-imports from dispatcher.ts.
No consumer; synthesizes id:0 which breaks correlation/cancellation; SEP-2598 (which would govern the mapping) is still draft. Re-add when a real gRPC transport is in scope.
…askManager directly OutboundMiddleware was a 4-hook record (request/notification/response/close) with first-claim-wins composition, registered via useOutbound() on McpServer/ Client/Protocol. Its only consumer was TaskManager, and it created a second, incompatible middleware pattern alongside Dispatcher.use(). Now: StreamDriver takes `taskManager?: TaskManager` and calls processOutbound*/ processInboundResponse/onClose at explicit call sites. McpServer/Client/Protocol pass their TaskManager via attach options. TaskManager.attachTo() returns void (inbound use() registration + handler install only). Dispatcher.use() (the standard (next)=>fn inbound pattern) is unchanged.
…p DispatchEnv alias - core/public no longer exports RequestTransport or isRequestTransport (kept in the internal barrel; shttpHandler can be public without exposing the second transport interface before SEP-2598 settles). - server no longer exports Backchannel2511 (constructed internally by shttpHandler/streamableHttp; it exists to be deleted when 2025-11 sunsets, so making it API surface defeats that). - DispatchEnv deprecated alias deleted (RequestEnv is the name). - Outbound was already not in public exports — confirmed unchanged.
…scriminator ChannelTransport gets `readonly kind?: 'channel'` (optional, back-compat). RequestTransport / ClientTransport get `readonly kind: 'request'` (required). isRequestTransport / isChannelTransport check the brand instead of probing for `onrequest` / `fetch` / `start` / `send`. The SHTTP transport classes now `implements RequestTransport` only (not also ChannelTransport — the brands are mutually exclusive). They keep the start/send/onmessage methods for back-compat with v1 callers, but McpServer/Client.connect() routes them via the request-shaped path.
Removed speculative '2026-06+', 'subscriptions/listen', and version-labeled 'native' references from JSDoc. Reworded to describe current behavior or cite the SEP. Kept the '2026-06-30' version literal in shttpHandler's negotiated-version gate (functional, not commentary) and 'pre-2026-06' phrasing in sessionCompat/ shttpHandler option docs (describes the back-compat path, not future behavior).
connect() overwrites _outbound on each call. Inbound dispatch works for all concurrent connections (each has its own onrequest/StreamDriver), but instance-level outbound (createMessage, send*ListChanged) reaches only the most-recently-connected transport. Matches v1 Protocol.connect semantics. Per the simplification review, documenting rather than redesigning.
…calls TaskManager directly
Invert the dependency per review: StreamDriver is now fully task-agnostic.
- StreamDriver exposes generic per-request `intercept` hook (RequestOptions) and
per-connection `onresponse` tap returning {consumed, preserveProgress}.
- TaskManager.sendRequest/sendNotification helpers wrap Outbound.request/notification,
threading processOutboundRequest through `intercept`.
- Protocol/McpServer/Client wire TaskManager via these helpers + the onresponse tap.
- McpServer request-shaped Outbound also honours `intercept`.
|
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a reference implementation, not for direct merge. It exists to prove the architecture in the RFC and WALKTHROUGH (also at https://gist.github.com/felixweinberger/823caeaa6e4130681bd249bb5bf1e06a).
The proposal: add
dispatch(request, env) → responseas the core primitive; build the channel/connection model as one adapter on top of it; isolate 2025-11 stateful behavior into deletable opt-in modules. Existing public APIs unchanged.How to read this PR
The diff is +14k/−5k because it's everything at once. Don't read the diff — read these files in order:
The proposal (start here):
docs/WALKTHROUGH.md— code walk through current pain → proposed splitdocs/rfc-stateless-architecture.md— diagram + the five new pieces + winsThe five new files (~1.7k LOC, this is the actual architecture):
3.
packages/core/src/shared/dispatcher.ts—dispatch(req, env)primitive4.
packages/core/src/shared/streamDriver.ts— channel adapter (correlation, timeouts)5.
packages/server/src/server/shttpHandler.ts— request adapter,(Request) → Response6.
packages/server/src/server/sessionCompat.ts— 2025-11 session state, opt-in7.
packages/server/src/server/backchannelCompat.ts— 2025-11 server→client over SSE, opt-inHow existing classes use them:
8.
packages/server/src/server/mcpServer.ts—McpServer extends Dispatcher,connect()builds the right adapter,handleHttp()9.
packages/server/src/server/streamableHttp.ts— gutted to ~290 LOC, routes throughshttpHandler10.
packages/core/src/shared/protocol.ts— back-compat shim, ~250 LOCSee it running:
examples/server/src/helloStateless.ts—app.post('/mcp', c => mcp.handleHttp(c.req.raw))examples/server/src/helloStatelessExpress.ts— both v1-style and new-direct side by sideIgnore:
packages/sdk/(meta-package, ~33 stub files) andpackages/server-auth-legacy/(~2400 LOC copied) — pure back-compat packaging with their own PRs (#1913, #1908).Shipping plan
If direction is agreed, this gets carved into 3 additive PRs (Dispatcher → shttpHandler+compat → StreamDriver), each ~500-800 LOC of new files only. Reroute of existing classes comes after.
State
SDK tests + integration + conformance (server 40/40, client 317/317) green; 14/14 tested OSS consumers typecheck after the existing v2 back-compat PRs.