Conversation
…n `2026-07-28`
* Replaced `@modelcontextprotocol/sdk@1` with `@modelcontextprotocol/{server,node}@2` and dropped Express, serving the web-standard handler on plain `node:http` through `toNodeHandler`
* Served protocol revision `2026-07-28` only, with `legacy: 'reject'`, rather than falling back to stateless 2025-era serving
* Capped request bodies at the `100kb` `express.json()` used to apply, since serving on `node:http` removes that parser and the SDK replaces neither it nor the limit
* Added `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS`, and made a routable bind with no Host allowlist fail at startup rather than warn as the SDK does
* Disabled confirm-before-execute in `execute_query`, which the next commit rebuilds on the multi-round-trip flow
* Pinned the integration-test client to `2026-07-28`, since `@modelcontextprotocol/client@2` negotiates the 2025 era by default and the suite would otherwise have covered the wrong wire era silently
* Replaced the `ping` liveness probe with `server/discover` and loosened a prompt-argument assertion, both of which this revision changed out from under the suite
* Replaced `execute_query`'s push-style `elicitInput()` call with an `input_required` return, so confirmation survives revision `2026-07-28` removing the server-to-client request channel * Refused clients that do not declare `elicitation` rather than executing unconfirmed * Restored the two confirmation tests unchanged * Added unit coverage for the confirmation states the integration suite cannot reach, because a well-behaved client never sends them * Consolidated the test clients onto one helper, dropping `withModernNegotiation` now that `connectMcpClient` can configure a client before it connects
* Sealed a digest of the built query, its variables and its endpoint into `requestState`, so an agent cannot show one query for confirmation and re-enter with another * Refused an answer carrying no `requestState` exactly like a mismatched one, since nothing forces a client to echo it and comparing only when present would leave the binding opt-out at the caller's discretion * Refused rather than re-asked on both failures, which would otherwise hand a caller an unlimited retry loop against the confirmation gate * Installed `codec.verify` as `ServerOptions.requestState.verify`, so a forged, expired or wrongly bound value is refused at the seam and never reaches the tool * Built the codec once per process and passed it through `McpServerDeps` rather than inside the per-request server factory, which would mint and verify the two rounds of one confirmation under different keys * Added `MCP_REQUEST_STATE_SECRET`, falling back to a per-process key and a startup warning, so a single replica needs no configuration and an operator running several is told why confirmations fail * Corrected `SERVER_INSTRUCTIONS`, which still told the model that a client without elicitation gets no prompt, describing the branch the previous commit replaced with a refusal
…2026-07-28` added * Published freshness hints on all six cacheable results, so the SDK default stops telling every client to cache nothing * Read `serverInfo.version` from the package manifest, now that the revision stamps it onto every result rather than onto a handshake that no longer exists * Pinned `server/discover`'s instructions and capabilities, `tools/list` ordering, and every cache hint as it reaches the wire * Recorded Arranger introspection caching as tech debt, since confirmation becoming two requests doubled the round trips a confirmed query costs and no commit here owns it, and removed two entries the SDK v2 migration had already obsoleted
* Stated the served revision and the modern-negotiation a consumer must opt into in `apps/mcp-server/README.md`, which still described the v1 SDK and named neither, and noted that `execute_query` refuses a client that cannot elicit * Recorded every operator-facing change of the upgrade in `CHANGELOG.md`, headlined by 2025-era clients no longer being served at all * Set `protocolEra: "modern"` in `mcp-inspector.json`, without which the Inspector negotiates 2025-era and this endpoint refuses it * Added `mcp-sdk-v2-changes.md` for a developer who needs what changed and why but not the alternatives rejected, covering the single-revision endpoint, the loss of sessions, `node:http` replacing Express, confirmation becoming two requests, the `requestState` binding, and the cache hints
justincorrigible
left a comment
There was a problem hiding this comment.
Really strong first pass at the v2 migration, and the design doc's reasoning holds up well against the code. There is just one thing that needs to be fixed before this can merge, and a few behavioral changes from the old transport are worth a deliberate decision rather than a silent carry-over.
Blocking: MCP_HOST=::1 500s on every request (see inline comment on http/server.ts). This is a documented supported value, so it's a full outage on a supported configuration, not an edge case.
Worth a decision before merge, each noted inline:
- The Content-Type gate that used to come from Express's default JSON parser is gone, with nothing explicit replacing it.
- Elicitation capability now has to be re-declared on every
tools/callrather than once per session, which is a real behavioural change for any client that only sends capabilities atinitialize. MCP_REQUEST_STATE_SECRETbeing unset degrades to a warning rather than a startup failure, unlike the equivalentMCP_HOST/MCP_ALLOWED_HOSTScase.
Everything else inline is lower-priority: a few efficiency opportunities from the new per-request server model, some duplicated constants that could import from the SDK instead, and a couple of nits.
| } | ||
| } | ||
|
|
||
| const { pathname } = new URL(req.url ?? '/', `http://${host}`); |
There was a problem hiding this comment.
This throws for an unbracketed IPv6 host.
MCP_HOST=::1 is documented in the README as a supported loopback value, but new URL('/x', 'http://::1') throws Invalid URL (IPv6 literals need brackets in a URL authority: http://[::1]). Every request lands in the outer catch and gets a 500, so the server can't serve a single request on this bind. None of the current tests catch it since they only bind to 127.0.0.1.
Suggested fix: bracket the host when it's a bare IPv6 literal before building the base URL, e.g. `http://${host.includes(':') && !host.startsWith('[') ? `[${host}]` : host}`, or reuse whatever bracketing helper the SDK may already expose for this (if one exists 🤞).
| } | ||
|
|
||
| try { | ||
| return { body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }; |
There was a problem hiding this comment.
The old transport went through Express' json() middleware, which only parses bodies with Content-Type: application/json by its default filter; anything else was left as an empty body and rejected downstream. readCappedJsonBody here parses every request body as JSON regardless of Content-Type, so that gate is gone.
The Origin guard still covers CSRF for browser traffic when MCP_ALLOWED_ORIGINS is set, but a non-browser caller, or a deployment where an upstream proxy doesn't cleanly pass Origin through, loses a layer of protection that previously existed as a side effect of the old parser.
Was dropping this deliberate, or worth adding back explicitly (i.e. checking Content-Type before attempting to parse)?
| const clientCanElicit = (ctx: ServerContext): boolean => { | ||
| const envelope = ctx.mcpReq.envelope as Record<string, unknown> | undefined; | ||
| const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; | ||
| return capabilities?.elicitation !== undefined; | ||
| }; |
There was a problem hiding this comment.
Two related things on this function:
-
Behavioural change from the old transport. The old
confirmExecution()readgetClientCapabilities(), negotiated once atinitializeand cached for the session: a client declared elicitation support once, and one that lacked it got a documented graceful fallback (query runs unconfirmed but is echoed back). Looks like protocol revision2026-07-28has no session-level state to fall back on, soclientCanElicit()now requires the capability to be re-asserted in the_metaenvelope of everytools/call. A client whose implementation only populates capabilities atinitialize(a plausible habit carried over from the session-based era) now gets a silent, permanent refusal on everyexecute_querycall instead of graceful degradation. Is this an accepted consequence of the protocol change, or worth a fallback/warning path for that class of client? -
Unchecked casts on peer-controlled input. The
_metaenvelope is narrowed viaas Record<string, unknown>andas ClientCapabilitiesrather than a Zod parse, even though this file validates every other piece of external input with a schema. A malformed envelope (e.g.elicitation: 0instead of an object) is currently read as "supports elicitation" by the!== undefinedcheck purely because the cast tells TypeScript to trust the shape. Low blast radius today (the round trip just fails downstream), but worth the same schema treatment as everything else here so a shape mismatch surfaces as a validation error instead of failing silently later.
| logger.warn( | ||
| 'MCP_REQUEST_STATE_SECRET is not set: query confirmations are signed with a key generated for this ' + | ||
| 'process. That is the intended default at a single replica. The key is not shared, so confirmations ' + | ||
| 'issued before a restart stop being answerable, and every confirmation fails across multiple replicas. ' + | ||
| 'Set MCP_REQUEST_STATE_SECRET when running more than one.', | ||
| ); |
There was a problem hiding this comment.
Read with concurrency and horizontal scalability in mind:
When MCP_REQUEST_STATE_SECRET is unset, confirmations are signed with a randomBytes(32) key generated fresh per process, with a warning logged (the message itself says "Set MCP_REQUEST_STATE_SECRET when running more than one"). Behind a load balancer with more than one replica, round one of a confirmation can be minted on replica A and routed to replica B for round two, which can't verify it and reports it as unbound. That's the default deployment shape here (multi-replica behind a load balancer), and there's currently no startup-time signal, unlike the equivalent MCP_HOST routable-without-MCP_ALLOWED_HOSTS case, which does hard-fail via superRefine. Worth the same treatment: fail startup (or at least fail loudly) when the host is non-loopback-only and the secret isn't set, rather than a log line an operator has to go looking for.
Two smaller things nearby, low priority: the bind() comment at line 83 says the method+clientId tag "today only stops state minted for one method being replayed against another," but clientId is always empty and method is always 'tools/call' in the current call graph, so the bind check is currently a no-op rather than an active defence, plus the tag is built with a single \0-delimited concatenation (${method}\0${clientId}), which isn't collision-free once clientId becomes real, attacker-influenced text. Neither is exploitable today (nothing calls this with varying method/clientId yet), but worth tightening the comment's wording and, whenever a second bindable dimension actually shows up, using a length-prefixed or JSON-encoded bind instead of raw concatenation.
| const deps: McpServerDeps = { config, client, requestStateCodec: createConfirmationCodec(config) }; | ||
| // One instance per request: the handler serves each request independently, so nothing is held | ||
| // between them and there is no session map to reap on shutdown. | ||
| const { close } = await startMcpHttpServer(config, () => createMcpServer(deps)); |
There was a problem hiding this comment.
The comment above this line explains why the server is built fresh per request, so my comment isn't asking whether that was intentional: it clearly was. However, the follow-on cost the comment doesn't mention: _createRegisteredTool eagerly runs standardSchemaToJsonSchema on every tool's input schema at registration time (confirmed directly in the SDK source), and createMcpServer reruns registerResources/registerTools/registerPrompts in full on every single incoming JSON-RPC request, not just tool calls, so that conversion (five tools including build_sqon's multi-branch union, three resources, one prompt) is redone from scratch on every request.
Worth being precise about what the SDK does and doesn't already handle here, since it's clearly aware of this cost shape: McpServer memoizes each tool's converted schema in _toolInputSchemaJson specifically so the scan at registration time, and the pre-dispatch validation step share one conversion "instead of paying it twice per request under the per-request-factory createMcpHandler model" (the SDK's own words)... But that cache lives on the McpServer instance, and a fresh instance is built per request here, so we'll only remove a duplication within the same request, not the across-request one: every request still pays the full conversion cost once, whereas a session-scoped server would pay it once total.
Worth confirming this per-request cost was weighed against the lifecycle simplification, since the two are separable: a schema-conversion cache keyed by schema identity and shared across instances would keep the per-request server lifecycle this comment argues for while removing the repeated conversion work.
| if (!res.headersSent) { | ||
| writeJsonRpcError(res, 500, -32603, 'Internal server error'); | ||
| } | ||
| res.end(); |
There was a problem hiding this comment.
Nit (ignore if you wish):
the catch block's res.end() runs unconditionally after writeJsonRpcError (which already ends the response) whenever !res.headersSent. Currently harmless (Node no-ops a second end()), but the trailing res.end() reads as though it's meant only for the headersSent === true branch and should probably be in an else.
| }); | ||
|
|
||
| const close = async () => { | ||
| await handler.close(); |
There was a problem hiding this comment.
Nit (ignore if you wish):
close() awaits handler.close() before httpServer.close(). In the window between the two, a request can still land, pass the guards, and reach handler.fetch(), which throws "This MCP handler has been closed," answered as a generic 500 instead of a clean shutdown-style refusal.
Reversing the order would avoid answering a pre-shutdown-eligible request with a fault response.
|
|
||
| import { readCappedJsonBody } from '#http/requestBody.js'; | ||
| import { type ArrangerMcpConfig } from '#utils/config.js'; | ||
| import logger from '#utils/logger.js'; |
There was a problem hiding this comment.
Nit (ignore if you wish):
this file imports the default logger directly, while the other new files in this PR (e.g. utils/config.ts, mcp/requestState.ts) use createLogger('ModuleName') for a prefixed logger 👍
Worth matching the convention this same PR establishes, for consistent log filtering (and because the prefixing does really help sometimes).
| * Hostnames the SDK's own localhost guards allow. `[::1]` is bracketed because both guards compare | ||
| * against `new URL(...).hostname`, which brackets IPv6 literals. | ||
| */ | ||
| const LOCALHOST_ALLOWED_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]']; |
There was a problem hiding this comment.
LOCALHOST_ALLOWED_HOSTNAMES hand-rolls the same array @modelcontextprotocol/server already exports as localhostAllowedHostnames()/localhostAllowedOrigins(). Importing instead of reimplementing removes a value that would otherwise silently drift if the SDK's list ever changes.
| const PARSE_ERROR = -32700; | ||
|
|
||
| /** Applied to a body over the configured ceiling. There is no JSON-RPC code for "too large". */ | ||
| const PAYLOAD_TOO_LARGE = -32600; |
There was a problem hiding this comment.
PARSE_ERROR and PAYLOAD_TOO_LARGE reimplement the SDK's own PARSE_ERROR/INVALID_REQUEST JSON-RPC error codes under different names; importing them from @modelcontextprotocol/server (already imported elsewhere in this same request path) would be a trivial swap.
Summary
Upgrades
apps/mcp-serverto MCP SDK v2 and protocol revision2026-07-28, which removes protocol sessions, theinitializehandshake, and server-initiated requests, so the transport and the confirm-before-execute flow both had to be rebuilt rather than ported. Breaking for consumers: the endpoint serves2026-07-28only, and every SDK client negotiates the 2025 era by default, so a host must opt into modern negotiation explicitly.Please refer to
.dev/docs/mcp-sdk-v2-changes.mdfor a short summary of the key changes in MCP v2 and how they impacted our MCP Server.Issues
Description of Changes
MCP Server
@modelcontextprotocol/{server,node}@2and protocol revision2026-07-28execute_query's confirmation: the SDK's legacy shim reads capabilities declared atinitialize, which per-request serving never seesnode:http, keeping the SDK's Host and Origin guards and re-adding the100kbbody capexpress.json()used to provideexecute_queryreturns aninput_requiredresult and the client re-invokes the tool with the answer, since servers can no longer initiate requestselicitation, rather than executing unconfirmed, which was the last route to running a query nobody approvedrequestState, which travels through the client and returns as untrusted inputttlMsandcacheScopeon the six cacheable results, and readserverInfo.versionfrom the package manifest now that the revision stamps it onto every resulthttp/app.tsandutils/inMemoryEventStore.ts, both of which existed only to manage sessions the revision removedIntegration Tests
2026-07-28, which no SDK client negotiates by default, so the suite would otherwise have covered the wrong wire era silentlypingliveness probe withserver/discoverDocumentation
.dev/docs/mcp-sdk-v2-changes.md, a short read on what changed and why, andmcp-sdk-v2-upgrade-plan.md, the implementation record behind each decisionapps/mcp-server/README.mdand the rootCHANGELOG.md, and set"protocolEra": "modern"inmcp-inspector.json, without which the MCP Inspector negotiates 2025-era and is refusedSpecial Instructions
Before running these changes, you will need to install the latest dependencies and rebuild your local modules:
Any MCP client used to test this branch must pin protocol revision
2026-07-28; the endpoint refuses anything else.New Environment Variables
MCP_ALLOWED_HOSTS: hostnames clients use to reach the server, matched againstHostfor DNS rebinding protection. Required wheneverMCP_HOSTis not loopback, which the0.0.0.0default is not, or the server exits at startup.MCP_ALLOWED_ORIGINS: browser origins allowed to call the server.MCP_MAX_BODY_BYTES: largest request body accepted, default102_400.MCP_REQUEST_STATE_SECRET: signs query confirmations. Optional at a single replica, required across several.For full details refer to the environment variable table in
apps/mcp-server/README.mdandapps/mcp-server/.env.schema.Readiness Checklist
.env.schemafile and documented in the README