MCP
TypeScript
Node.js
Zod
AI Agents
Streamable HTTP

MCP Server in TypeScript: Building One on the v2 SDK

Build an MCP server in TypeScript with the v2 SDK: registerTool with Zod schemas, stdio vs Streamable HTTP, stateless load balancing, and a v1-to-v2 diff table.

15 min read
Chamikara Nayanajith

To build an MCP server in TypeScript today, install @modelcontextprotocol/server, the v2 SDK, not the older @modelcontextprotocol/sdk. Create an McpServer, register each tool with registerTool and a Zod schema, and serve it over stdio for a local client or over Streamable HTTP for anything remote. The server built below is under 90 lines.

It is a real one: an MCP server that lets an AI assistant search and read the posts on this blog, built on the site's /llms.txt index and the Markdown it returns for Accept: text/markdown. The server, both entry points and the test clients were run against @modelcontextprotocol/server 2.2.0, @modelcontextprotocol/node 2.1.0, Zod 4.6.5, TypeScript 7.0.2 and Node 24.16.0, and every output quoted is what those versions printed.

Three results are worth knowing before you copy a tutorial. The v2 client silently skips a plain console.log line on stdio; a partial write is what hangs the call. A thrown Error already reaches the model as isError: true. And the v2 client speaks the 2025 protocol unless you opt in to the new one.

MCP sits underneath LLM tool calling. The model still decides which tool to call; MCP standardizes how a client discovers your tools and invokes them, so one server works in Claude Code, an IDE, or your own agent loop.

Which package do you install: sdk or server?

Install @modelcontextprotocol/server. The v1 package, @modelcontextprotocol/sdk, is still maintained and still holds npm's latest tag: 1.31.0 shipped on 2026-09-28, the same day as v2's 2.2.0. Copy the install line from a tutorial written before July 2026 and you get v1, with an API that v2 no longer has.

v2 went stable with 2.0.0 on 2026-07-27, a day before the 2026-07-28 MCP specification it implements. It splits the old single package into server, client and core, plus thin adapters such as @modelcontextprotocol/node and @modelcontextprotocol/express. It needs Node 20 or later and Zod 4.2 or later.

bash
npm install @modelcontextprotocol/server zod
npm install @modelcontextprotocol/node   # only for Streamable HTTP on node:http
npm install -D @modelcontextprotocol/client typescript tsx @types/node

The examples are ES modules, so set "type": "module" in package.json. On TypeScript 6 or later, also add "types": ["node"] to compilerOptions. TypeScript stopped including @types/* automatically, and the server package README points out that its declarations reference Buffer.

Registering a tool with Zod input and output schemas

A tool is a name, a description the model reads, an input schema and a handler. In v2 the schema is a Zod object passed as inputSchema, and the SDK converts it into the JSON Schema that clients show the model. Here is the whole server: a search_posts tool with structured output, and a get_post tool that returns Markdown.

typescript
// src/server.ts
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod';

const SITE = 'https://cnayanajith.com';

const Post = z.object({
  slug: z.string(),
  title: z.string(),
  description: z.string(),
  url: z.string(),
});
type Post = z.infer<typeof Post>;

// llms.txt lists each post as "- [Title](https://cnayanajith.com/blog/slug): Summary"
async function loadPosts(): Promise<Post[]> {
  const res = await fetch(`${SITE}/llms.txt`);
  if (!res.ok) throw new Error(`llms.txt returned ${res.status}`);
  const text = await res.text();
  const section = text.split('## Blog Posts')[1]?.split('\n## ')[0] ?? '';
  return section.split('\n').flatMap((line) => {
    const m = line.match(/^- \[(.+?)\]\((.+?)\): (.+)$/);
    if (!m) return [];
    const [, title, url, description] = m;
    return [{ slug: url.split('/').pop()!, title, url, description }];
  });
}

export function createServer() {
  const server = new McpServer({ name: 'cnayanajith-blog', version: '1.0.0' });

  server.registerTool(
    'search_posts',
    {
      title: 'Search blog posts',
      description:
        'Find posts on cnayanajith.com whose title or summary contains every word of the query. Returns slugs to pass to get_post.',
      inputSchema: z.object({
        query: z.string().min(2).describe('Keywords, for example "rate limiting" or "useEffect"'),
        limit: z.number().int().min(1).max(10).default(5),
      }),
      outputSchema: z.object({ posts: z.array(Post) }),
      annotations: { readOnlyHint: true },
    },
    async ({ query, limit }) => {
      const words = query.toLowerCase().split(/\s+/);
      const posts = (await loadPosts())
        .filter((p) => {
          const haystack = `${p.title} ${p.description}`.toLowerCase();
          return words.every((w) => haystack.includes(w));
        })
        .slice(0, limit);
      const output = { posts };
      return {
        content: [{ type: 'text', text: JSON.stringify(output) }],
        structuredContent: output,
      };
    },
  );

  server.registerTool(
    'get_post',
    {
      title: 'Read a blog post',
      description: 'Return the full Markdown of one post. Use a slug from search_posts.',
      inputSchema: z.object({
        slug: z.string().regex(/^[a-z0-9-]+$/).describe('Post slug, for example "api-rate-limiting"'),
      }),
      annotations: { readOnlyHint: true },
    },
    async ({ slug }) => {
      const res = await fetch(`${SITE}/blog/${slug}`, {
        headers: { Accept: 'text/markdown' },
      });
      if (res.status === 404) {
        return {
          isError: true,
          content: [
            { type: 'text', text: `No post with slug "${slug}". Call search_posts to find a valid slug.` },
          ],
        };
      }
      return { content: [{ type: 'text', text: await res.text() }] };
    },
  );

  return server;
}

A few lines in there matter more than they look.

  • .describe() is the parameter documentation. The model never sees your TypeScript types. It sees the JSON Schema, and the description string travels with the field. An example value in the description does more than a paragraph in the tool description.
  • outputSchema is a promise. The MCP tools specification says a server that declares one MUST return structuredContent that conforms to it, and SHOULD also return the same JSON as a text block for older clients. That is why the handler returns both.
  • createServer is a factory, not a singleton. Both ways of serving in v2 take a function that builds a server, because the HTTP entry builds a fresh instance for every request. Keep connection pools and caches at module scope, outside the factory.
  • readOnlyHint is a hint. Clients use annotations to decide how hard to ask for confirmation, and the spec tells them to distrust annotations from servers they do not trust. It is not a permission system.

Arguments are parsed against the schema before your handler runs, the same job Zod validation does at the edge of an HTTP API. The test in the next section shows what the model gets back when a call fails that check.

Serving over stdio and testing with the SDK client

stdio is the default for local servers. The client launches your server as a child process and exchanges newline-delimited JSON-RPC over stdin and stdout. In v2 the entry point is three lines:

typescript
// src/stdio.ts
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './server.js';

serveStdio(createServer);

You do not need a chat app to test it. The v2 client package can spawn the server and call its tools from a script, which also makes it the easiest thing to run in CI:

typescript
// src/client-stdio.ts
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const client = new Client({ name: 'smoke-test', version: '1.0.0' });
await client.connect(
  new StdioClientTransport({ command: 'npx', args: ['tsx', 'src/stdio.ts'] }),
);

const { tools } = await client.listTools();
console.log(tools.map((t) => t.name));

const search = await client.callTool({ name: 'search_posts', arguments: { query: 'rate limiting' } });
console.log(JSON.stringify(search.structuredContent, null, 2));

const missing = await client.callTool({ name: 'get_post', arguments: { slug: 'no-such-post' } });
console.log(missing);

const invalid = await client.callTool({ name: 'get_post', arguments: { slug: 'Not A Slug!' } });
console.log(invalid);

await client.close();

npx tsx src/client-stdio.ts printed:

bash
[ 'search_posts', 'get_post' ]
{
  "posts": [
    {
      "slug": "api-rate-limiting",
      "title": "API Rate Limiting: Token Bucket, Redis, and 429 Headers",
      "url": "https://cnayanajith.com/blog/api-rate-limiting",
      "description": "Rate limit an API properly: fixed window vs sliding window vs token bucket, a Redis implementation, the RateLimit headers to send, and per-user keying."
    }
  ]
}
{
  content: [
    {
      type: 'text',
      text: 'No post with slug "no-such-post". Call search_posts to find a valid slug.'
    }
  ],
  isError: true
}
{
  content: [
    {
      type: 'text',
      text: 'Input validation error: Invalid arguments for tool get_post: slug: Invalid string: must match pattern /^[a-z0-9-]+$/'
    }
  ],
  isError: true
}

The last result is the one to notice. Not A Slug! fails the regex, so the SDK rejected it before get_post ran and answered with isError: true and a message naming the field. The model gets something it can correct on the next call.

To use the server from Claude Code, register the command:

bash
claude mcp add blog -- npx tsx /absolute/path/to/src/stdio.ts

Claude Desktop takes the same command from an mcpServers block in its claude_desktop_config.json:

json
{
  "mcpServers": {
    "blog": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/src/stdio.ts"]
    }
  }
}

Why does console.log corrupt a stdio MCP server?

On stdio, stdout is the protocol channel. Every line the server writes there must be one JSON-RPC message, and the stdio transport specification says a server MUST NOT write anything else there. Logs belong on stderr, which is where console.error writes. How badly a stray write breaks things depends on exactly what was written.

To find out, one line was added to the top of the get_post handler, three different ways, and the tool was called from the v2 client each time:

Line added to the handlerWhat the v2 client did
console.log('fetching post', slug)Skipped the line. The call succeeded.
console.log(JSON.stringify({ tool: 'get_post', slug }))Fired onerror with a Zod invalid_union error. The call succeeded.
process.stdout.write('fetching... ')The call failed after 60,002 ms with Request timed out.

That first row is milder than the official tutorial's warning that writing to stdout "will corrupt the JSON-RPC messages and break your server." The v2 client's line reader runs JSON.parse on each line and silently skips any line that throws a SyntaxError, so a plain-text log disappears. A line that is valid JSON but not a JSON-RPC message gets past the parse, fails schema validation, and lands in onerror. None of that tolerance comes from the protocol; it is how this client is written, and another client is allowed to treat either line as fatal.

The fix is the same in every case: send logs to stderr, with console.error or a logger pointed at file descriptor 2. Check your dependencies too. dotenv 17 printed its startup banner to stdout by default (both 17.0.0 and 17.4.2 did when tested for this post), which is exactly the kind of line the spec forbids. Version 18 moved that banner to stderr, but with debug: true it still writes to stdout.

Should a tool throw or return isError?

Return isError: true for failures the model can act on, and let truly unexpected failures throw. In v2 both reach the model the same way: the SDK catches an Error thrown from a handler and turns it into a tool result with isError: true and the error's message as the text. The difference is what that message says.

The handler above returns its own result for a missing slug, which gave the model No post with slug "no-such-post". Call search_posts to find a valid slug. Swap the return for a throw:

typescript
if (res.status === 404) {
  throw new Error(`No post with slug "${slug}"`);
}

and the client receives:

bash
{
  content: [ { type: 'text', text: 'No post with slug "no-such-post"' } ],
  isError: true
}

Same flag, less guidance. It gets worse with errors you did not write. Pointing get_post at an unreachable host made fetch throw, and the model received exactly this:

bash
{ content: [ { type: 'text', text: 'fetch failed' } ], isError: true }

Nothing in fetch failed tells a model whether to retry, try another slug or give up. Catch it where you know what it means:

typescript
let res: Response;
try {
  res = await fetch(`${SITE}/blog/${slug}`, { headers: { Accept: 'text/markdown' } });
} catch {
  return {
    isError: true,
    content: [
      { type: 'text', text: 'cnayanajith.com could not be reached. Retry once, then tell the user the blog is down.' },
    ],
  };
}

Pointed at the same unreachable host, that version returned its own message with isError: true. A 500 from the site slips through the original handler too, returned as if it were a post, so give it an if (!res.ok) branch with the same kind of message.

The tools specification draws the same line. Tool execution errors go back in the result so the model can self-correct, and protocol errors, such as an unknown tool or a malformed request, are JSON-RPC errors that clients may keep from the model entirely. Calling a tool name that does not exist is that second kind, and it rejects the client's promise instead of returning a result:

bash
ProtocolError -32602 Tool get_posts not found

Watch for the missing flag in copied code. The TypeScript example in the official build an MCP server tutorial returns "Failed to retrieve alerts data" as ordinary content without isError, so a client records that failure as a successful call.

stdio or Streamable HTTP: which transport should you ship?

Ship stdio when the server runs on the user's own machine for one user. Ship Streamable HTTP when it runs somewhere else or serves more than one person.

stdioStreamable HTTP
Who starts itThe client spawns it as a child processYou deploy it; clients connect to a URL
UsersOne client per processMany clients, many instances
AuthRuns with the user's own permissionsYours to add: bearer tokens or OAuth
Network exposureNoneValidate Origin; bind to 127.0.0.1 when local
Where logs gostderr onlyAnywhere

Streamable HTTP is one endpoint that accepts POST. Every JSON-RPC request is its own POST, and the server answers each one with either a single JSON body or an SSE stream scoped to that request. The older HTTP+SSE transport, with its long-lived GET stream, has been deprecated since the 2025-03-26 revision, and v2 removed SSEServerTransport outright.

In v2 the HTTP entry is createMcpHandler, and it takes the same factory as stdio. On plain node:http, wrap it with toNodeHandler and put the Host and Origin checks in front of it. The handler validates neither, and the Streamable HTTP specification requires Origin validation to block DNS rebinding attacks.

typescript
// src/http.ts
import { createServer as createHttpServer } from 'node:http';
import { createMcpHandler } from '@modelcontextprotocol/server';
import {
  localhostHostValidation,
  localhostOriginValidation,
  toNodeHandler,
} from '@modelcontextprotocol/node';
import { createServer } from './server.js';

const port = Number(process.env.PORT ?? 3000);
const mcp = toNodeHandler(createMcpHandler(createServer));
const validHost = localhostHostValidation();
const validOrigin = localhostOriginValidation();

createHttpServer(async (req, res) => {
  if (!validHost(req, res) || !validOrigin(req, res)) return;
  if (req.url !== '/mcp') {
    res.writeHead(404).end();
    return;
  }

  await mcp(req, res);
}).listen(port, '127.0.0.1');

The guards answer a rejected request with 403 themselves. For a deployed server, swap the localhost helpers for hostHeaderValidation and originValidation, which take the hostnames you allow. Claude Code connects with claude mcp add --transport http blog http://127.0.0.1:3000/mcp.

Running it stateless behind a load balancer

The 2026-07-28 revision made the protocol stateless. There is no initialize handshake and no Mcp-Session-Id. Every request carries the protocol version, client info and client capabilities in its own _meta, so any instance can answer any request. On HTTP, the method and tool name are also copied into Mcp-Method and Mcp-Name headers, so a proxy can route or limit without parsing the body.

To test that, the HTTP server ran as two instances on ports 4701 and 4702 behind a plain round-robin proxy with no sticky sessions, each logging the two headers to stderr. One client connection made five requests:

bash
[:4701] POST server/discover
[:4702] POST tools/list
[:4701] POST tools/call search_posts
[:4702] POST tools/call search_posts
[:4701] POST tools/call search_posts

Every request landed on a different instance from the one before it, and all three searches returned results. Against a sessionful 2025 server, the second request would have reached an instance that had never seen the session.

typescript
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

const client = new Client(
  { name: 'smoke-test', version: '1.0.0' },
  { versionNegotiation: { mode: { pin: '2026-07-28' } } },
);
// 4700 is the round-robin proxy in front of :4701 and :4702; use :3000 for src/http.ts alone
await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:4700/mcp')));

Pinning fails loudly against an older server. Use { mode: 'auto' } when the client has to talk to both. It probes with server/discover and picks 2026-07-28 only on a definite modern answer. Most other replies, including an unrecognized error, fall back to initialize; over HTTP, an outage, a timeout, a 401 or 403, or a 5xx rejects instead. Check client.getProtocolEra() after connecting so a silent fallback shows up: over stdio against this server it returned legacy for a default client and modern with mode: 'auto'.

Rate limiting on the Mcp-Method header

The tools specification says servers MUST rate limit tool invocations, and the header makes a cheap version easy: count requests, leave listing free. Trust the header only on a request that declares 2026-07-28, though, because that is the only kind the SDK checks against the body; the next section shows what happens otherwise. The counter and helper go at module scope, and the check goes in the request handler just before await mcp(req, res):

typescript
// 10 counted requests per minute per client; 2026-07-28 listing is free.
const WINDOW_MS = 60_000;
const MAX_CALLS = 10;
const calls = new Map<string, { count: number; resetAt: number }>();

function overLimit(key: string): boolean {
  const now = Date.now();
  const entry = calls.get(key);
  if (!entry || entry.resetAt <= now) {
    calls.set(key, { count: 1, resetAt: now + WINDOW_MS });
    return false;
  }
  entry.count += 1;
  return entry.count > MAX_CALLS;
}

// inside the request handler, before await mcp(req, res).
// The SDK checks Mcp-Method against the body only on 2026-07-28 requests,
// so anything else counts, whatever its header claims.
const modern = req.headers['mcp-protocol-version'] === '2026-07-28';
const method = req.headers['mcp-method'];
const free = modern && (method === 'tools/list' || method === 'server/discover');
if (!free && overLimit(req.socket.remoteAddress ?? 'unknown')) {
  res.writeHead(429, { 'Retry-After': '60' }).end();
  return;
}

On a fresh server, curl sent one successful call, then the two malformed requests from the next section, then twelve more tools/call requests. The twelve returned:

bash
200 200 200 200 200 200 200 200 429 429 429 429

Ten counted requests got through, and one of them was the malformed request with no Mcp-Method header, which counts because only a 2026-07-28 listing request is free. A tools/list sent while limited still got a 200.

The counter lives in process memory, so behind two instances each one allows its own ten. Behind the round-robin proxy above, req.socket.remoteAddress is the proxy's address, so every client shares one bucket; take the client address from a forwarding header only when your own proxy sets it. For a shared limit, keep the counter in Redis, as in the API rate limiting post, which also covers the X-Forwarded-For trap, and key it on the authenticated user once you have auth.

Can a client lie in the Mcp-Method header?

Yes, over the 2025 protocol. A client could send Mcp-Method: tools/list with a tools/call body to slip past the count. On a 2026-07-28 request, the SDK compares header and body and refuses the mismatch before any tool runs:

bash
HTTP/1.1 400 Bad Request
content-type: application/json

{"jsonrpc":"2.0","error":{"code":-32020,"message":"Bad Request: the request headers and body disagree: the body names method tools/call but the Mcp-Method header names tools/list","data":{"mismatch":{"header":"tools/list","body":"the body names method tools/call but the Mcp-Method header names tools/list"}}},"id":1}

A 2026-07-28 request with no Mcp-Method header also got a 400, and so did one that claimed 2026-07-28 in the header without the version in its body. A 2025-era request is different: createMcpHandler's legacy fallback serves it without checking Mcp-Method, and that includes every request from a v2 Client left on its default.

Against a limiter that trusted the header alone and counted only tools/call, a tools/call body sent with Mcp-Method: tools/list and no MCP-Protocol-Version returned 200 and ran the tool, and fourteen headerless calls in a row all returned 200 under a limit of ten. That is why the check above counts everything except a 2026-07-28 listing request, the same principle the spec gives intermediaries that act on mirrored headers. It still counts requests, not tool calls: a JSON-RPC batch of five tools/call messages, which the legacy fallback still accepts, came back as five results in one 200 response and counted once.

If every client you serve speaks 2026-07-28, createMcpHandler(createServer, { legacy: 'reject' }) closes the legacy path entirely. A 2025-style request then gets a 400 with error code -32022, Unsupported protocol version: the request did not name a protocol version, a batch gets a 400 with JSON-RPC batches are not supported by this endpoint, and the pinned client keeps working. If you have to keep 2025 clients, parse the body yourself with a size cap, count each message in an array, and pass the parsed body on as mcp(req, res, body).

Migrating a v1 server to v2

Run the official codemod first, then fix what it cannot reach. On a small v1 server it rewrote the imports and converted server.tool to registerTool:

bash
npx @modelcontextprotocol/codemod@latest v1-to-v2 .
typescript
// before (v1)
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({ name: 'cnayanajith-blog', version: '1.0.0' });

server.tool(
  'get_post',
  'Return the full Markdown of one post',
  { slug: z.string() },
  async ({ slug }) => ({ content: [{ type: 'text', text: slug }] }),
);

await server.connect(new StdioServerTransport());
typescript
// after the codemod, then prettier
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';

const server = new McpServer({ name: 'cnayanajith-blog', version: '1.0.0' });

server.registerTool(
  'get_post',
  {
    description: 'Return the full Markdown of one post',
    inputSchema: z.object({ slug: z.string() }),
  },
  async ({ slug }) => ({ content: [{ type: 'text', text: slug }] }),
);

await server.connect(new StdioServerTransport());

The codemod does not format its output, so run your formatter after it, then search for @mcp-codemod-error, the marker it leaves where it could not convert something. Skip the codemod and call the old method on a v2 server, and TypeScript reports error TS2339: Property 'tool' does not exist on type 'McpServer', while plain JavaScript fails at startup with TypeError: server.tool is not a function. The rest of the mapping, from the v1 to v2 upgrade guide:

v1v2
@modelcontextprotocol/sdk@modelcontextprotocol/server, /client, /core, plus adapters
server.tool(name, description, shape, handler)server.registerTool(name, { description, inputSchema }, handler)
@modelcontextprotocol/sdk/server/stdio.js@modelcontextprotocol/server/stdio
StreamableHTTPServerTransportcreateMcpHandler, or NodeStreamableHTTPServerTransport from @modelcontextprotocol/node
SSEServerTransportRemoved; use Streamable HTTP
McpErrorProtocolError
extra.signal, extra.authInfoctx.mcpReq.signal, ctx.http?.authInfo
setRequestHandler(CallToolRequestSchema, ...)setRequestHandler('tools/call', ...)
Zod 3Zod 4.2 or later

A large codebase can keep both packages installed and migrate one directory at a time. The upgrade guide flags one trap: objects must not cross between v1 and v2 code, because instanceof checks and nominal types fail across that boundary.

When an MCP server is the wrong tool

If only your own application calls the tool, you do not need MCP. Define the tool in your LLM API request and run the loop yourself. MCP earns its overhead when the same tools have to work in clients you do not control: Claude Code, an IDE, or somebody else's agent.

It is also worth asking whether an agent needs a server at all. This blog already serves /llms.txt and Markdown to anything that can fetch a URL, so the server above mainly adds typed inputs, a two-tool surface, and a clear error when a slug does not exist. For a public, read-only site, that is a thin gain. For anything behind auth, or anything that writes, it is the whole point.

So: stdio for a tool one person runs locally, and stateless Streamable HTTP through createMcpHandler as soon as a second person needs it. Pin exact versions, because the SDK went from 2.0 to 2.2 in two months. Log to stderr from the first commit. Return isError with a message a model can act on. And pin your test client to 2026-07-28, so you are testing the protocol you think you are shipping.

Frequently asked questions

Should I build an MCP server in TypeScript or Python?

Build it in the language of the code it wraps. The protocol is identical on the wire, so a client cannot tell which SDK a server was written with. TypeScript fits when the tools call into a Node.js codebase, npm packages, or web APIs you already describe with Zod, and the v2 TypeScript SDK implements the 2026-07-28 specification, including stateless Streamable HTTP. Python fits when the tools wrap data libraries, notebooks or an existing Python service. For a server that mostly calls HTTP APIs, either works, so pick the one your team will maintain. Whichever you choose, spend the effort on tool descriptions and error messages, because those are the parts the model actually reads.

Is SSE still supported in MCP?

Partly. The old HTTP+SSE transport, a long-lived GET stream plus a separate POST endpoint, has been deprecated since the 2025-03-26 revision, and the v2 TypeScript SDK removed SSEServerTransport. Server-Sent Events still appear inside Streamable HTTP, though: when a server answers a single POST with a stream, for example to send progress notifications before the final result, that response uses the text/event-stream format. The 2026-07-28 revision also dropped the standalone GET stream and resumable streams via Last-Event-ID. You still want proxies that do not buffer event streams, but you no longer build a separate SSE endpoint.

How do I test a stdio MCP server?

Use the client package from the same SDK. In v2, @modelcontextprotocol/client ships a StdioClientTransport that spawns your server as a child process, so a short script can list the tools, call each one and print the results without any chat app. The same script works as a CI test: assert on structuredContent for the success cases and on isError for failures such as invalid arguments. Two things are worth testing on purpose: that nothing except JSON-RPC reaches stdout, and that bad input comes back as a tool result with isError set to true rather than a crash. For HTTP servers the same client works with StreamableHTTPClientTransport; pin versionNegotiation to 2026-07-28 so the test exercises the current protocol.

Does a stateless MCP server need sticky sessions?

No, not for clients that speak the 2026-07-28 revision. That revision removed the initialize handshake and the Mcp-Session-Id header, and every request carries its own protocol version and client capabilities in _meta, so any instance can answer any request and a plain round-robin load balancer works. Older clients are the exception, because the 2025 protocol created a session on one instance. The v2 TypeScript SDK's createMcpHandler serves those clients statelessly by default and answers their GET and DELETE session requests with 405, so they work without affinity too. If you still run an older sessionful server, you need sticky routing or a shared session store.

Can an MCP server keep state between tool calls?

Yes, but explicitly. The 2026-07-28 protocol has no session, so a server cannot attach state to a connection. The tools specification recommends returning a handle from a creation tool, such as a basket or transaction ID, and accepting that handle as an argument on later calls. The model carries the handle forward, and the server stores the state under that key in a database or cache every instance can reach. Check the caller's authorization against the handle on every call, use an unguessable ID such as a UUIDv4 when there is no auth, state the expiry in the tool description, and return a tool error with isError set to true when a handle has expired so the model can create a new one.

Related Articles