MCP spec and SDK audit (2026-10)
Issue: #5139. Context: #5134, #5066, #5132, #4978, #4485.
Date: 2026-10-04 (ET). This is research only. It changes no code, and any
migration it recommends needs its own vote.
Repo paths starting with src/ are relative to packages/nexus-agents/. SDK paths
are relative to the installed @modelcontextprotocol/sdk@1.31.0 package unless stated.
1. Version drift
| Claim | Evidence | Verdict |
|---|---|---|
SDK range ^1.31.0 |
packages/nexus-agents/package.json:86 |
as stated |
Resolved SDK 1.31.0 |
pnpm-lock.yaml:150-152; installed node_ |
matches |
Protocol 2025-11-25 |
CLAUDE.md:628, AGENTS.md:610; installed SDK dist/esm/types.js:2 LATEST_ |
accurate for the SDK in use |
| Latest v1 SDK | npm view @modelcontextprotocol/sdk dist-tags gives latest: 1.32.0 (released 2026-10-02) |
one minor behind |
| Latest v2 SDK | npm view @modelcontextprotocol/server gives 2.3.0; @modelcontextprotocol/server@2.0.0 was released 2026-07-27 (releases) |
one major behind |
| Current spec | 2026-07-28 changelog | one revision behind |
#4485 says 2026-07-28 support is “blocked on the SDK”. That is no longer true.
SDK v2 implements the revision. Its server bundle exports DiscoverRequest,
UnsupportedProtocolVersionError, PROTOCOL_VERSION_META_KEY, subscriptions/listen
types and inputRequired (MRTR), and it ships a migration guide,
docs/migration/support-2026-07-28.md (fetched with gh api). The 1.x line still
has no 2026-07-28 support.
#4485’s trigger can never fire. Its trigger is “watch for LATEST_PROTOCOL_VERSION
= 2026-07-28”. The v2 core still declares LATEST_PROTOCOL_VERSION = "2025-11-25"
(@modelcontextprotocol/core@2.3.0, dist/auth-*.mjs), because that constant names
the newest handshake-era revision. The real signal is the v2 package line itself.
What changed in 2026-07-28 that affects us (all from the changelog above):
- The handshake is gone: requests carry the version in
_meta, andserver/discoveris a MUST (SEP-2575). Sessions are removed as well (SEP-2567). list_changednotifications are delivered over an opt-insubscriptions/listenstream (SEP-2575).- Tasks move out of core into the
io.modelcontextprotocol/tasksextension.tasks/resultandtasks/listare removed in favour of pollingtasks/get(SEP-2663). - MRTR (
InputRequiredResult) replaces server-initiatedroots/list,sampling/createMessageandelicitation/create(SEP-2322). - Roots, Sampling and Logging are deprecated, with a removal window of at least 12 months (SEP-2577).
logging/setLevelis removed from the wire. outputSchemamay use any JSON Schema 2020-12 keyword, andstructuredContentmay be any JSON value (SEP-2106).
What 1.31.0 → 1.32.0 changed that affects us (1.32.0 notes):
InMemoryTaskStore now keeps each task within the session that created it (PR #2925),
which is the store src/mcp/task-store.ts:14 wraps. The release also adds an opt-in
maxToolInputElements.
2. Hand-rolled features against what the protocol and SDK now cover
2a. outputSchema derivation (#5134, #5066)
-
SDK: no SDK version links the declared
outputSchemato the handler’s return type.- In v1.31, the
registerTool<OutputArgs, InputArgs>callback isToolCallback<InputArgs>and returns a plainCallToolResult(dist/esm/server/mcp.d.ts:150-157,261). - v2 2.3.0 is the same (
createMcpHandler-*.d.mts:3372-3382,3539). v2 accepts any Standard Schema and deprecates the raw-shape form (:3383), but nothing type-checks the callback’sstructuredContent.
- In v1.31, the
-
The root cause is not “hand-maintained”, and this was measured. The SDK checks the output one way and advertises it another:
- The SDK advertises
outputSchemathrough ZodtoJSONSchema(..., io: 'output'), which emitsadditionalProperties: falseforz.object(mcp.js:88-95). - The server’s own check is a non-strict Zod
safeParse(mcp.js:186-205). It silently strips extra keys and passes. - A probe against the installed SDK confirmed this:
{a, extra}passes the server, while the advertised schema forbidsextra. - Only a client running Ajv over the advertised JSON fails (
client/index.js:475-488). v2 behaves the same way (mcp-*.mjs:1820-1826). With zod 4.6.5, the v2 conversion path (~standard.jsonSchema.output) still emitsadditionalProperties: false. - v2’s guide says “no
additionalProperties: falseby default”, but it is describing the generatedinputSchema(upgrade-to-v2.md:1517-1521).
- The SDK advertises
-
Today: 18 tool modules declare
outputSchema(grep -lE "outputSchema(,|:)" src/mcp/tools). They return throughtoolSuccessStructured(data: Record<string, unknown>)(src/mcp/tools/tool-result.ts:106), which is untyped, so the compiler sees no link between schema and return. The only guard is the SDK-Clientround-trip suite (src/mcp/mcp-standalone-tools.test.ts:149,196), and it can only check response shapes it actually exercises (#5066). -
Recommendation: build, small. No SDK feature exists to adopt. The cheapest step that removes the class has two parts:
- Declare output schemas with
z.strictObject(or.strict()), so the server’s ownsafeParsefails exactly where the advertised schema does. Every handler-level test then catches an undeclared key without needing a protocol round-trip. - Add a typed
toolSuccessStructured<S>(schema: S, data: z.output<S>)so drift is a compile error.
consensus-vote.test.ts:1785already uses.strict()this way in a test. Doing it for the other 17 schemas is mechanical. - Declare output schemas with
2b. listChanged (#5132)
-
SDK: already present in the 1.31 SDK we run.
McpServerregisterstools: { listChanged: true }as soon as the first tool is registered (mcp.js:62-66). We already advertise the capability.- Every
RegisteredToolhasenable(),disable()andremove(), each of which emitssendToolListChanged(mcp.js:605-651).tools/listfilters onenabled(mcp.js:67-69). - In v2,
send*ListChanged()is routed ontosubscriptions/listenautomatically (support-2026-07-28.md:612-621).
-
Client: Claude Code re-fetches tools on
list_changed(docs). That fixes anthropics/claude-code#77314, closed 2026-07-18, and #79826, closed 2026-08-15. -
Today: no
enable(),disable()orsendToolListChangedcall appears anywhere undersrc/(grep). We advertise a capability we never exercise. -
Recommendation: needs a decision, and the objection is weakened. #5132’s strongest counter was “registration is fixed”. The SDK makes it dynamic in one call per tool, and the main client honours it. A transient prerequisite can call
disable()at boot andenable()when the adapter resolves.Keep the actionable-error requirement for the
disabledwindow. A hidden tool still gives the operator no reason. Consider exposing that reason through a diagnostic tool such asdoctor.Smallest step: a spike on one tool (
run_workflow) behind a flag, plus a round-trip test that asserts thelist_changednotification and the re-listed set.
2c. Tasks vs runAsJob (#4978)
-
Spec/SDK: the 2025-11-25 tasks primitive that
execute_expertuses is removed from SDK v2. The removed surface includesregisterToolTask,InMemoryTaskStore, thetaskStoreconstructor option andexperimental.tasks.*, and there is “no mechanical migration; remove usages” (upgrade-to-v2.md:1754-1770). The spec moved tasks to an extension with different semantics (SEP-2663). v2 serves it only through explicit-schemasetRequestHandler('tasks/get', …)(support-2026-07-28.md:357-366). -
Today:
execute_expertusesserver.experimental.tasks.registerToolTask(src/mcp/tools/execute-expert.ts:682-696) and sits outside the standard middleware stack (:659-665).- The server declares
capabilities.tasksand passestaskStore(src/mcp/server.ts:113-119), andsrc/mcp/task-store.tsis 138 lines. - About 10 tools use
runAsJob(src/mcp/jobs/run-as-job.ts, 608 lines). The non-testsrc/mcp/jobs/tree is about 2,900 lines.
-
Recommendation: needs a decision, and the vote should now favour keeping the job surface. Any v2 upgrade forces #4978: the tasks lane goes away. Converging
execute_expertontorunAsJobwould:- remove the only tool outside the middleware stack,
- close the unshared-limiter gap,
- delete
task-store.tsand thetaskscapability.
Exposing jobs through the new tasks extension is a separate, later call. No consumer needs it today, and the extension is not in the SDK runtime.
2d. Deprecated surfaces (#4485)
- Logging: already migrated. Operator events go to stderr (
src/mcp/mcp-notifier.ts:21,50-63; commit9ca0e0b3b2, #6947), and nosendLoggingMessagecall remains insrc/(grep). Tick #4485 part 2a. - Roots: still load-bearing.
src/mcp/workspace-roots.ts:174callsserver.server.listRoots().- Claude Code now sets
CLAUDE_PROJECT_DIRin the spawned server’s environment as “the stable project root” (docs). - That is a server-configuration channel of the kind SEP-2577 recommends, and it is synchronous, which also removes the #4002 pre-resolution race. No
src/code reads it today (grep). - Recommendation: adopt
CLAUDE_PROJECT_DIR, thenNEXUS_DATA_DIR, ahead ofroots/list. Keeproots/listas the fallback until the 2027-07-28 removal window.
- Claude Code now sets
- Sampling, HTTP+SSE: not used (#4485 “checked and not affected” still holds).
2e. Other primitives
| Primitive | Ours | Evidence |
|---|---|---|
| Progress | implemented | src/mcp/mcp-notifier.ts:102-106 (progressToken, sendNotification) |
| Cancellation | implemented (request-scoped) | src/cli-server-shutdown.ts:43 (extra.signal); jobs add their own abort registry (#4978) |
| Prompts / resources | implemented | src/cli-server-tools.ts:977-978 |
| Resource subscriptions | absent | no subscribe handler under src/mcp/resources (grep) |
| Elicitation | absent | no elicitInput in src/ (grep). Under 2026-07-28 this becomes MRTR inputRequired, which is v2-only |
| Sampling | absent | see 2d |
3. Client-behaviour assumptions
- Spec: “Servers MUST provide structured results that conform to this schema. Clients SHOULD validate” (tools spec, 2026-07-28). The #5134 schemas therefore broke a server MUST. Client validation is optional, so “works in our harness” proves nothing.
- SDK
Client: validatesstructuredContentwith Ajv (client/index.js:475-488). - Claude Code: whether it validates
structuredContentis UNVERIFIED. Its MCP docs do not say, and #5134’s observation of “no validation” is the only evidence.
Summary
| Feature | Spec / SDK status | Ours | Closes | Call |
|---|---|---|---|---|
| Protocol 2026-07-28 | current spec; SDK v2 only | 2025-11-25 on v1.31 | #4485 part 1 | needs decision (v2 major upgrade) |
| SDK 1.32.0 | latest v1 | 1.31.0 | — | adopt (patch-level risk) |
| outputSchema derivation | not in SDK v1 or v2 | hand-listed and untyped; strip-vs-strict mismatch | #5134 class, #5066 class | build: strictObject and a typed helper |
listChanged |
SDK enable/disable; Claude Code honours it |
advertised, never used | #5132 | needs decision (spike on one tool) |
| Tasks | removed from SDK v2; spec extension | execute_ only |
#4978 | needs decision (lean: converge on runAsJob) |
| Logging | deprecated | migrated to stderr | #4485 2a | done; tick it |
| Roots | deprecated | load-bearing listRoots |
#4485 2b, #4002 | adopt CLAUDE_ first |
| Elicitation / MRTR | v2-only | absent | — | keep absent (no consumer) |
| Resource subscriptions | replaced by subscriptions/listen |
absent | — | keep absent |