Repository navigation
feat(server): let a low-level Server take part in HTTP preflight checks - #3128
sacha-ichbiah wants to merge 4 commits into
Conversation
createMcpHandler ran SEP-2243 Mcp-Param-* header validation and OAuth scope challenges only when the factory returned an McpServer, so a factory returning a low-level Server skipped both without notice. Server gains setToolInputSchemaResolver and setScopeChallengeHandler. McpServer sets both on its own Server from its registry, and the entries read them from whichever instance the factory returns. Server.connect now installs the scope challenge resolver on a Streamable HTTP transport, so McpServer.connect no longer does it itself. Refs modelcontextprotocol#3126 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 31a4a4c The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
Read 2b6f3ab (not run). One ordering gap in Either install a thunk that reads Smaller point: for a low-level |
Server.connect installed the handler it had at connect time, so on the Streamable HTTP transport a handler set afterwards was ignored while createMcpHandler honored it. The transport now gets a resolver that reads the handler per request. The docs also say the tool input schema resolver must return the schema tools/list emits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, both fixed in 47235af.
|
|
Read 47235af (not run). The request-time read closes the ordering gap, and the three cases in One consequence worth settling: since the resolver reads |
setScopeChallengeHandler and setToolInputSchemaResolver replace any previous value, like setRequestHandler; a replaced scope challenge handler applies from the next request, including on an open Streamable HTTP session, which a new test pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replacement is meant to be supported, matching I left clearing out, as you noted: there is no |
|
Read dbb2835 (not run). The new test covers the ordering that mattered: 403 under One case the test does not pin: a request already in flight when the handler is swapped. Is it checked against the handler read when it arrived, or can it see the new one mid-request? I suspect the first, since the preflight runs before dispatch, but a one-line comment or assertion would settle it for people rotating scopes at runtime. |
| } | ||
| // Read at request time, so a handler set after connect() still applies. | ||
| if (supportsScopeChallengeResolver(transport)) { | ||
| transport.setScopeChallengeResolver(context => this._scopeChallenge?.(context)); |
There was a problem hiding this comment.
setScopeChallengeResolver is public on both Streamable HTTP transports, and before this PR a low-level Server never touched it, so someone could have been calling it on the transport directly to get scope challenges with a plain Server. With this line server.connect(transport) replaces that resolver, and if setScopeChallengeHandler was never called the new one always returns undefined, so that setup quietly stops sending 403s. Is that worth handling, or at least a line in the changeset? It currently says a low-level Server without the new setters is served as before.
Server.connect replaced any resolver set directly on a Streamable HTTP transport, even for a low-level Server that never set a handler, so that setup silently stopped answering 403. connect now installs the server's resolver only when a handler is set, and setScopeChallengeHandler installs it on the connected transport when called later. The JSDoc states that each request is checked once, before dispatch, against the handler in place when its check runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@notlakshitha good catch, that was a regression. Fixed in 31a4a4c: @arhancanli on requests in flight: the check runs once, before dispatch, and dispatch never re-checks, so a single request is judged by the handler in place when its check runs; a swap after that does not affect it. One nuance on the legacy transport: batch members are checked one after another, so a swap while an async handler is still deciding can apply to the later members. The |
Implements the handler-free option from #3126:
createMcpHandlerran SEP-2243Mcp-Param-*header validation and OAuth scope challenges only when the factory returned anMcpServer, so a factory returning a low-levelServerskipped both without notice.Change
ServergainssetToolInputSchemaResolver(resolver)andsetScopeChallengeHandler(handler).McpServersets both on its ownServerfrom its registry (toolInputSchemaJson,resolveScopeChallenge), so its behavior is unchanged.createMcpHandlerreads them from whichever instance the factory returns, instead of checkinginstanceof McpServer. No handler-level option is added, in line with the existing "register it on the primitive" design.Server.connectinstalls the scope challenge resolver on a Streamable HTTP transport, so the legacy path covers a low-levelServertoo andMcpServer.connectno longer does it itself.ToolInputSchemaResolveris exported as a type; the internal accessorpreflightHooksOffollows the existing package-internal pattern (serverIdentityOf) and is not exported from the index.Tests
mcpParamValidation.test.ts: a low-levelServerwith a resolver gets400/-32020on a mismatched header and dispatches on a matching one; without a resolver nothing is checked.scopeChallengeModern.test.ts: a low-levelServerwithsetScopeChallengeHandler(requireScopes(...))is challenged403before dispatch throughcreateMcpHandler, and dispatches once the scope is present.scopeChallenge.test.ts: the same on the legacy Streamable HTTP transport viaServer.connect.pnpm lint:allandpnpm typecheck:allpass.pnpm test:allpasses exceptprotocol:timeout:max-totalin the e2e suite, which also fails on unmodifiedmainin this environment (both thesse 2025-11-25andsse 2026-07-28variants) and passes when run alone.Docs: one paragraph in
docs/advanced/low-level-server.md. Changeset:@modelcontextprotocol/serverminor.I'm aware CONTRIBUTING asks for the issue to be settled first; happy to change the shape (for example exporting the helpers instead, the other option in #3126) or close this if maintainers prefer a different direction.
🤖 Generated with Claude Code