Skip to content

feat(server): let a low-level Server take part in HTTP preflight checks - #3128

Open
sacha-ichbiah wants to merge 4 commits into
modelcontextprotocol:mainfrom
sacha-ichbiah:feat/server-preflight-hooks
Open

sacha-ichbiah wants to merge 4 commits into
modelcontextprotocol:mainfrom
sacha-ichbiah:feat/server-preflight-hooks

Conversation

@sacha-ichbiah

@sacha-ichbiah sacha-ichbiah commented Oct 29, 2026 •

Copy link
Copy Markdown

Implements the handler-free option from #3126: 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.

Change

  • Server gains setToolInputSchemaResolver(resolver) and setScopeChallengeHandler(handler).
  • McpServer sets both on its own Server from its registry (toolInputSchemaJson, resolveScopeChallenge), so its behavior is unchanged.
  • createMcpHandler reads them from whichever instance the factory returns, instead of checking instanceof McpServer. No handler-level option is added, in line with the existing "register it on the primitive" design.
  • Server.connect installs the scope challenge resolver on a Streamable HTTP transport, so the legacy path covers a low-level Server too and McpServer.connect no longer does it itself.
  • ToolInputSchemaResolver is exported as a type; the internal accessor preflightHooksOf follows the existing package-internal pattern (serverIdentityOf) and is not exported from the index.

Tests

  • mcpParamValidation.test.ts: a low-level Server with a resolver gets 400/-32020 on a mismatched header and dispatches on a matching one; without a resolver nothing is checked.
  • scopeChallengeModern.test.ts: a low-level Server with setScopeChallengeHandler(requireScopes(...)) is challenged 403 before dispatch through createMcpHandler, and dispatches once the scope is present.
  • scopeChallenge.test.ts: the same on the legacy Streamable HTTP transport via Server.connect.

pnpm lint:all and pnpm typecheck:all pass. pnpm test:all passes except protocol:timeout:max-total in the e2e suite, which also fails on unmodified main in this environment (both the sse 2025-11-25 and sse 2026-07-28 variants) and passes when run alone.

Docs: one paragraph in docs/advanced/low-level-server.md. Changeset: @modelcontextprotocol/server minor.

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

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>
@sacha-ichbiah
sacha-ichbiah requested a review from a team as a code owner October 29, 2026 17:53
@changeset-bot

changeset-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 31a4a4c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/codemod Minor
@modelcontextprotocol/core Minor
@modelcontextprotocol/server-legacy Minor
@modelcontextprotocol/core-internal Patch

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

@pkg-pr-new

pkg-pr-new Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@3128

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@3128

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@3128

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 31a4a4c

@arhancanli

Copy link
Copy Markdown

Read 2b6f3ab (not run). One ordering gap in Server.connect: it reads this._scopeChallenge once, into a local const, and installs it on the transport only if it is already set. So on the legacy transport path, server.connect(t) followed by server.setScopeChallengeHandler(h) silently leaves that transport unchallenged, while createMcpHandler (which reads the hooks per request via preflightHooksOf) would honour the same late call. For McpServer it is fine because the constructor sets the handler before anyone can connect.

Either install a thunk that reads this._scopeChallenge at call time, or have setScopeChallengeHandler throw/forward to the transport when already connected. A test of connect-then-set on a Streamable HTTP transport would pin whichever you pick; the new tests only cover the createMcpHandler path.

Smaller point: for a low-level Server the resolver is a second source of truth next to the tools/list handler. Is it worth a docs line saying it must return the same inputSchema that tools/list emits, since a drift means headers get validated against a schema clients never saw?

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>
@sacha-ichbiah

Copy link
Copy Markdown
Author

Thanks, both fixed in 47235af.

  • Ordering: Server.connect now installs a resolver that reads the handler at request time (context => this._scopeChallenge?.(context)), so a handler set after connect() applies on the Streamable HTTP transport too, matching createMcpHandler. scopeChallenge.test.ts now covers set-before-connect, set-after-connect, and no handler (dispatches normally).
  • Second source of truth: the JSDoc on setToolInputSchemaResolver and the low-level server guide now say it must return exactly the inputSchema tools/list advertises, since headers are validated against what it returns.

@arhancanli

Copy link
Copy Markdown

Read 47235af (not run). The request-time read closes the ordering gap, and the three cases in scopeChallenge.test.ts (before connect, after connect, none) pin it. The JSDoc and guide wording on setToolInputSchemaResolver now say what has to match.

One consequence worth settling: since the resolver reads this._scopeChallenge on every request, calling setScopeChallengeHandler a second time now swaps the handler for requests already in flight on an open session. Is replacement meant to be supported? If so a one-line test for it would pin that; if not, throwing on a second call would be the stricter choice. The parameter type has no undefined, so there is no way to clear it either, which is consistent with 'set once'.

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>
@sacha-ichbiah

Copy link
Copy Markdown
Author

Replacement is meant to be supported, matching setRequestHandler ("this will replace any previous request handler for the same method"). dbb2835 says so in the JSDoc of both setters and adds a test in scopeChallenge.test.ts: on an open Streamable HTTP session, a replaced handler applies from the next request (403 under requireScopes('repo:write'), then 200 after switching to repo:read).

I left clearing out, as you noted: there is no undefined parameter, and a handler that always returns undefined already turns the challenge off if someone needs that.

@arhancanli

Copy link
Copy Markdown

Read dbb2835 (not run). The new test covers the ordering that mattered: 403 under requireScopes('repo:write') with a repo:read token, then 200 after the swap on the same open session, so the handler is read per request and the replacement semantics match setRequestHandler. Agreed on clearing: a handler that returns undefined is the off switch, so no extra parameter is needed.

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.

Comment thread packages/server/src/server/server.ts Outdated
}
// Read at request time, so a handler set after connect() still applies.
if (supportsScopeChallengeResolver(transport)) {
transport.setScopeChallengeResolver(context => this._scopeChallenge?.(context));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Oct 30, 2026
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>
@sacha-ichbiah

Copy link
Copy Markdown
Author

@notlakshitha good catch, that was a regression. Fixed in 31a4a4c: Server.connect now installs its resolver only when a handler is set, and setScopeChallengeHandler installs it on the already-connected transport when called after connect(). A low-level Server that never sets a handler leaves a resolver set directly with setScopeChallengeResolver in place; a new test pins that, and the changeset now says so. McpServer is unaffected: it sets its handler in the constructor, so connect installs it as before.

@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 setScopeChallengeHandler JSDoc now states the per-request rule.

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

v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants