Skip to content

feat: Upgrade apps/mcp-server to MCP SDK v2 and protocol revision 2026-07-28 - #1099

Draft
mistryrn wants to merge 5 commits into
mainfrom
feat/mcp-v2-migration
Draft

mistryrn wants to merge 5 commits into
mainfrom
feat/mcp-v2-migration

Conversation

@mistryrn

@mistryrn mistryrn commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Upgrades apps/mcp-server to MCP SDK v2 and protocol revision 2026-07-28, which removes protocol sessions, the initialize handshake, 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 serves 2026-07-28 only, 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.md for a short summary of the key changes in MCP v2 and how they impacted our MCP Server.

Issues

Description of Changes

MCP Server

  • Upgraded to @modelcontextprotocol/{server,node}@2 and protocol revision 2026-07-28
  • Dropped support for 2025-era clients, because that path cannot carry execute_query's confirmation: the SDK's legacy shim reads capabilities declared at initialize, which per-request serving never sees
  • Replaced Express with plain node:http, keeping the SDK's Host and Origin guards and re-adding the 100kb body cap express.json() used to provide
  • Rebuilt confirm-before-execute on the multi-round-trip flow: execute_query returns an input_required result and the client re-invokes the tool with the answer, since servers can no longer initiate requests
  • Refused clients that do not declare elicitation, rather than executing unconfirmed, which was the last route to running a query nobody approved
  • Bound the approval to the query it approved, by sealing an HMAC-signed digest of the built query into requestState, which travels through the client and returns as untrusted input
  • Published ttlMs and cacheScope on the six cacheable results, and read serverInfo.version from the package manifest now that the revision stamps it onto every result
  • Deleted http/app.ts and utils/inMemoryEventStore.ts, both of which existed only to manage sessions the revision removed

Integration Tests

  • Pinned the test client to 2026-07-28, which no SDK client negotiates by default, so the suite would otherwise have covered the wrong wire era silently
  • Replaced the ping liveness probe with server/discover

Documentation

  • Added .dev/docs/mcp-sdk-v2-changes.md, a short read on what changed and why, and mcp-sdk-v2-upgrade-plan.md, the implementation record behind each decision
  • Updated apps/mcp-server/README.md and the root CHANGELOG.md, and set "protocolEra": "modern" in mcp-inspector.json, without which the MCP Inspector negotiates 2025-era and is refused

Special Instructions

Before running these changes, you will need to install the latest dependencies and rebuild your local modules:

npm ci                 # dependencies changed: the v1 SDK and Express are gone
npm run modules:build  # rebuild workspace 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 against Host for DNS rebinding protection. Required whenever MCP_HOST is not loopback, which the 0.0.0.0 default 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, default 102_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.md and apps/mcp-server/.env.schema.

Readiness Checklist

  • Self Review
    • I have performed a self review of code
    • I have run the application locally and manually tested the feature
    • I have checked all updates to correct typos and misspellings
  • Formatting
    • Code follows the project style guide
    • Automated code formatters (ie. Prettier) have been run
  • Local Testing
    • Successfully built all packages locally
    • Successfully ran all test suites, all unit and integration tests pass
  • Updated Tests
    • Unit and integration tests have been added that describe the bug that was fixed or the features that were added
  • Documentation
    • All new environment variables added to .env.schema file and documented in the README
    • All changes to server HTTP endpoints have open-api documentation
    • All new functions exported from their module have TSDoc comment documentation

…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 justincorrigible left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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/call rather than once per session, which is a real behavioural change for any client that only sends capabilities at initialize.
  • MCP_REQUEST_STATE_SECRET being unset degrades to a warning rather than a startup failure, unlike the equivalent MCP_HOST/MCP_ALLOWED_HOSTS case.

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}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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')) };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)?

Comment on lines +230 to +234
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;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two related things on this function:

  1. Behavioural change from the old transport. The old confirmExecution() read getClientCapabilities(), negotiated once at initialize and 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 revision 2026-07-28 has no session-level state to fall back on, so clientCanElicit() now requires the capability to be re-asserted in the _meta envelope of every tools/call. A client whose implementation only populates capabilities at initialize (a plausible habit carried over from the session-based era) now gets a silent, permanent refusal on every execute_query call instead of graceful degradation. Is this an accepted consequence of the protocol change, or worth a fallback/warning path for that class of client?

  2. Unchecked casts on peer-controlled input. The _meta envelope is narrowed via as Record<string, unknown> and as ClientCapabilities rather than a Zod parse, even though this file validates every other piece of external input with a schema. A malformed envelope (e.g. elicitation: 0 instead of an object) is currently read as "supports elicitation" by the !== undefined check 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.

Comment on lines +58 to +63
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.',
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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]'];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment on lines +18 to +21
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants