Skip to content

An MCP Server in Your Workflow, Checked Against the Spec

The MCP spec is on revision 2026-07-28, the SDK you npm install speaks 2025-11-25. A typed component-index server, tested with the SDK's own client.

· · 10 min read

Quick Take

The Model Context Protocol has four dated revisions and a fifth that rewrote the handshake. Here is which one your server actually speaks, a small typed tool that indexes React components, and a test harness that needs no IDE at all.

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/sdk still negotiates 2025-11-25 at most, and that's fine, because every client I know of does too. Build your tool with registerTool, give it an inputSchema and an outputSchema, 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.

RevisionWhat it added or removedDoes sdk 1.32.1 speak it?
2024-11-05the baseline most published servers were written againstyes
2025-03-26Streamable HTTP, SSE transport deprecated, OAuth 2.1 authorizationyes, and it's the default negotiated version
2025-06-18structured tool output, elicitationyes
2025-11-25icons on tools, URL-mode elicitation, experimental tasks, JSON Schema 2020-12 as default dialectyes, latest
2026-07-28stateless protocol, server/discover, sessions removed, Roots and Sampling and Logging deprecatedno

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:

  • initialize and notifications/initialized are gone. Each request carries io.modelcontextprotocol/protocolVersion and the client capabilities in _meta, and a mismatch returns UnsupportedProtocolVersionError.
  • server/discover is 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-Id header 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/setLevel and notifications/roots/list_changed are removed. Log level rides along per request in _meta.
  • Results from tools/list, resources/list and friends must now carry ttlMs and cacheScope, 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.

Share this Post on X Bluesky

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.

Frequently Asked Questions

Which MCP protocol version does the TypeScript SDK support?
The 1.x line, at 1.32.1 as of October 5, 2026, declares 2025-11-25 as its latest protocol version and lists five supported revisions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 and 2024-10-07. The spec site lists 2026-07-28 as current, so a server built on the 1.x SDK talks one revision behind the spec. The split 2.x packages (@modelcontextprotocol/server, client and core, at 2.3.1) reference the 2026-07-28 revision and the server/discover method in their type definitions.
Do I need to rewrite my MCP server for the 2026-07-28 revision?
Not today. The revision removes the initialize handshake and the Mcp-Session-Id header, adds a mandatory server/discover RPC and deprecates Roots, Sampling and Logging, but the feature lifecycle policy keeps deprecated features in the spec for at least twelve months. A tools-only server on the 1.x SDK keeps working with any client that negotiates 2025-11-25 or earlier, and the 2.x server package's type definitions describe a legacy route for 2025-era peers.
How do I test an MCP server without Claude Code or Cursor?
Use the SDK's own Client with InMemoryTransport.createLinkedPair(). One side connects to your McpServer, the other acts as the host, and listTools and callTool return exactly what a real client would see, including the isError results for bad arguments. The whole harness is about thirty lines and runs under node with no network.
Does the MCP SDK work with Zod 4?
Yes. @modelcontextprotocol/sdk 1.32.1 declares a peer range of ^3.25 || ^4.0 for zod, and the server in this article compiled and ran against zod 4.6.5. Validation failures come back as tool results with isError true and a message that names the field, for example 'Too small: expected string to have >=1 characters at query'.