Everything below ran on 2026-10-09 with @modelcontextprotocol/sdk 1.32.1, zod 4.6.5, typescript 7.0.2 and Node 22.22.0. I built the server, compiled it under strict, and called it with the SDK's own client over an in-memory transport. I did not run it inside an IDE this time, and the last section says what that leaves unverified.
TL;DR: The spec moved to revision 2026-07-28 and rewrote the handshake. The 1.x SDK you get from
npm install @modelcontextprotocol/sdkstill negotiates 2025-11-25 at most, and that's fine, because every client I know of does too. Build your tool withregisterTool, give it aninputSchemaand anoutputSchema, and test it with the SDK client before you ever touch.mcp.json.
Which Revision Does Your Server Actually Speak?
Not the one on the spec's front page. That's the first thing to get straight, because "MCP support" in a tool's changelog hides a version number.
MCP revisions are dated strings. The spec's versioning page lists 2026-07-28 as the current revision, and it describes a protocol that no longer has an initialize call: every request carries its protocol version in _meta, and a server has to answer server/discover with the versions it supports. That's a big change. So I checked what the SDK I'd been using says about itself:
sdk latest: 2025-11-25
supported: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07
That's LATEST_PROTOCOL_VERSION and SUPPORTED_PROTOCOL_VERSIONS as exported from @modelcontextprotocol/sdk/types.js in version 1.32.1, published on October 5, 2026. The 1.x line is one full revision behind the spec, and it hasn't pretended otherwise.
| Revision | What it added or removed | Does sdk 1.32.1 speak it? |
|---|---|---|
| 2024-11-05 | the baseline most published servers were written against | yes |
| 2025-03-26 | Streamable HTTP, SSE transport deprecated, OAuth 2.1 authorization | yes, and it's the default negotiated version |
| 2025-06-18 | structured tool output, elicitation | yes |
| 2025-11-25 | icons on tools, URL-mode elicitation, experimental tasks, JSON Schema 2020-12 as default dialect | yes, latest |
| 2026-07-28 | stateless protocol, server/discover, sessions removed, Roots and Sampling and Logging deprecated | no |
Is being behind a problem? For a tools-only server, no. The 2026-07-28 changelog says deprecated features stay in the spec for at least twelve months under the feature lifecycle policy, and the 2.x server package's type definitions describe an "inbound legacy route" for 2025-era peers next to the modern server/discover probe, so the old handshake is still answered on the side that moved. Where it bites is the other direction. If you build on the new 2.x packages (@modelcontextprotocol/server, client and core, all at 2.3.1 on the same day), their type definitions reference the "2026-07-28 era" and declare server/discover as a cacheable result method. I read those .d.cts files; I didn't port my server to them, so treat 2.x as "exists and tracks the new revision" rather than "tested here".
What Changed in 2026-07-28, in One Screen?
The changelog is long. These are the entries that change what a server author writes:
initializeandnotifications/initializedare gone. Each request carriesio.modelcontextprotocol/protocolVersionand the client capabilities in_meta, and a mismatch returnsUnsupportedProtocolVersionError.server/discoveris mandatory. It returns supported versions, capabilities and identity, and a client may call it first to pick a version.- Protocol-level sessions and the
Mcp-Session-Idheader are removed from Streamable HTTP. State that has to survive between calls becomes a server-minted handle passed as an ordinary tool argument. ping,logging/setLevelandnotifications/roots/list_changedare removed. Log level rides along per request in_meta.- Results from
tools/list,resources/listand friends must now carryttlMsandcacheScope, so a host can cache your tool list instead of polling it. - Roots, Sampling and Logging are deprecated. The suggested migration for Roots is the one I'd have written anyway: pass the directory as a tool parameter.
The deprecation that surprised me was Sampling. The spec's suggested replacement is "integrate directly with LLM provider APIs", which is an admission that a server asking the host's model to think on its behalf never found many takers. Fine by me. A tool should compute, not chat.
How Do You Build a Typed Tool Worth Keeping?
The earlier MCP explainer for TypeScript used server.tool(), which still works. The current shape is registerTool, and it takes an outputSchema next to the inputSchema. That second schema is the part I'd argue for hardest, so the example leans on it.
The tool answers one question an AI assistant asks constantly in a React codebase: "is there already a component called something like X?" It scans a map of file paths to source text, which in a real server you'd replace with readdir and readFile.
// server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
export const PROJECT_FILES: Record<string, string> = {
'src/Button.tsx': 'export function Button({ label }: { label: string }) { return <button>{label}</button>; }',
'src/Card.tsx': 'export function Card({ title }: { title: string }) { return <section>{title}</section>; }',
};
export function createServer() {
const server = new McpServer({ name: 'component-index', version: '0.1.0' });
server.registerTool(
'find_component',
{
title: 'Find component',
description: 'Find React components in src/ whose export name contains the query',
inputSchema: { query: z.string().min(1).describe('Substring of the component name') },
outputSchema: { matches: z.array(z.object({ file: z.string(), exportName: z.string() })) },
},
async ({ query }) => {
const matches = Object.entries(PROJECT_FILES)
.map(([file, source]) => {
const m = /export function (\w+)/.exec(source);
return { file, exportName: m ? m[1] : '' };
})
.filter((c) => c.exportName.toLowerCase().includes(query.toLowerCase()));
const structuredContent = { matches };
return {
content: [{ type: 'text', text: JSON.stringify(structuredContent) }],
structuredContent,
};
},
);
return server;
}
Three details carry the weight. z.string().min(1) means an empty query is rejected before my handler runs, and you'll see below exactly what the client receives when that happens. structuredContent is what a host can parse without guessing; the text block duplicates it for hosts that only read text. And createServer() returns the server without connecting a transport, which is the whole reason the next section is possible: the same function serves a stdio process in production and an in-memory pair in a test.
The compiled tools/list entry, as the client saw it, carries the Zod constraint straight through:
{"type":"object","properties":{"query":{"type":"string","minLength":1,"description":"Substring of the component name"}},"required":["query"]}
I've cut one field from that line for width: a $schema key that points at JSON Schema draft-07. Notice the draft number: even though 2025-11-25 made JSON Schema 2020-12 the default dialect. The SDK's conversion still emits draft-07, which every client I've met accepts, but it's a reminder that "supports 2025-11-25" is a floor, not a promise that every detail moved.
A tool without an outputSchema is a tool whose results the host has to guess at, and a guessing host is how an assistant confidently cites a field that doesn't exist.
How Do You Test It Without Opening an IDE?
With the SDK's own client. InMemoryTransport.createLinkedPair() hands you two ends of a pipe; the server connects to one, a Client to the other, and from then on listTools and callTool behave exactly as they would from Claude Code or Cursor, minus the process boundary.
// test.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from './server.js';
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await createServer().connect(serverTransport);
const client = new Client({ name: 'test-harness', version: '0.0.0' });
await client.connect(clientTransport);
console.log(JSON.stringify(client.getServerCapabilities()));
const ok = await client.callTool({ name: 'find_component', arguments: { query: 'but' } });
const empty = await client.callTool({ name: 'find_component', arguments: { query: '' } });
const wrong = await client.callTool({ name: 'find_component', arguments: { q: 'x' } });
console.log(JSON.stringify(ok), JSON.stringify(empty), JSON.stringify(wrong));
await client.close();
What came back, trimmed only for width:
capabilities: {"tools":{"listChanged":true}}
ok: {"content":[{"type":"text","text":"{\"matches\":[{\"file\":\"src/Button.tsx\",\"exportName\":\"Button\"}]}"}],
"structuredContent":{"matches":[{"file":"src/Button.tsx","exportName":"Button"}]}}
empty: {"content":[{"type":"text","text":"MCP error -32602: Input validation error: Invalid arguments for tool
find_component: Too small: expected string to have >=1 characters at query"}],"isError":true}
wrong: {"content":[{"type":"text","text":"MCP error -32602: Input validation error: Invalid arguments for tool
find_component: Invalid input: expected string, received undefined at query"}],"isError":true}
Two things I'd not have known without running it. First, a validation failure does not reject the promise. callTool resolves with isError: true and the Zod message inside a text block, which is what the 2025-11-25 changelog asked for: input validation errors are tool execution errors, so the model can read them and try again. Your harness needs to assert on isError, not wrap the call in try/catch. Second, the wrong-argument case names the missing field rather than listing the unexpected one. An assistant that sent q instead of query gets told query is undefined, which is enough to self-correct.
Why do I insist on this step? Because registering a server in an IDE and watching the assistant poke at it is the slowest possible feedback loop, and it tells you nothing about the edge cases. The in-memory pair runs in under a second. Put it in your test suite next to your Vitest component tests and the tool gets checked on every commit, which no IDE integration will ever do for you.
How Does It Reach Claude Code or Cursor?
The same way the earlier guide showed: a .mcp.json at the project root with a command and args that start the server over stdio. The Claude Code workflow for React projects walks through where that file sits in a day of feature work, and the Storybook MCP addon is the ready-made version of the component index above, if your project already has stories.
Add a StdioServerTransport to the server, and keep it out of createServer() so the test harness still works:
// main.ts
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createServer } from './server.js';
await createServer().connect(new StdioServerTransport());
One rule the 2025-11-25 changelog made explicit: on stdio, stderr is for all logging, not only errors. Anything you write to stdout corrupts the JSON-RPC stream, and the failure looks like a client that silently ignores your server. If a tool disappears from the list and nothing else is wrong, grep your code for console.log.
Should You Start on the 2.x Packages?
My opinion, and reasonable people will disagree: not for a tools-only server you want to ship this month. Here's the reasoning, all from package metadata I could read rather than behaviour I measured.
The 2.x line split the SDK into @modelcontextprotocol/server, client and core, all at 2.3.1 as of October 5, 2026, and the server package's type definitions talk about the "2026-07-28 era", server/discover and an inbound legacy route for "2025-era" peers. So it's clearly where the new revision lands first. But core 2.3.1 still exports LATEST_PROTOCOL_VERSION as 2025-11-25, the same value as 1.32.1, which tells me the two eras coexist inside it rather than one having replaced the other. The MCP Apps extension already peers on these packages at its 2.0 line, as the MCP Apps in React write-up on this site found, so anything UI-shaped will pull you to 2.x whether you planned it or not.
If you're writing a tool that returns text or structured data, 1.32.1 is boring in the good sense. If you're writing a host, or anything that needs ttlMs on its lists, read the 2.x changelog first.
What Didn't I Verify?
I didn't register this server in Claude Code, Cursor or VS Code during this run, so the hands-on comparison the topic deserved, which client negotiates which revision, isn't here. I didn't port the server to the 2.x packages, so every statement about them above comes from their published .d.cts files and package.json, not from a running process. And the component index reads from an in-memory map, not a filesystem. The protocol facts, the SDK version constants and every byte of the tool output are real; the IDE matrix is the next piece of work.