· 14 min read
Build an MCP v2 Server in TypeScript with the New MCP SDK
Build a stateless MCP v2 server in TypeScript. A hands-on guide to the MCP TypeScript v2 SDK — the new @modelcontextprotocol/server packages, createMcpHandler, Standard Schema tools, and what replaces session handling.
Copy a command, then paste it into the command palette (Ctrl K to open).
Introduction
MCP v2 is final. Protocol revision 2026-07-28 shipped, and the TypeScript SDK shipped with it: @modelcontextprotocol/server 2.0.0 and its companion packages are on npm as stable releases.
If you want a v2 server running today, this guide gets you there. The fastest path is one command:
npx @agentailor/[email protected] --name=fetch-mcp-server
That scaffolds a working MCP v2 server. The rest of this article explains what that command just did for you, using the v1 to v2 diff as the teaching device. Most of what changed is plumbing you no longer have to write.
What you'll learn:
- How to scaffold a v2 server and what the generated code does
- Why the session-management code from v1 is simply gone
- The new package layout, and how tool schemas changed
- How to migrate an existing v1 server
Prerequisites:
- Node.js 22.19 or later (see the note below; this is higher than v1 required)
- Basic TypeScript familiarity
New to MCP entirely? MCP is an open standard that lets AI models discover and call external tools, with three capability types: tools (actions), resources (readable data), and prompts (templates). If none of that is familiar, read Create Your First MCP Server in 5 Minutes first. It covers the concepts and transports at a beginner pace on v1, and everything conceptual there still holds. This guide assumes you have that grounding and focuses entirely on v2.
NOTE
The Node floor moved up. The v2 packages themselves are not what forces it: the requirement comes from @modelcontextprotocol/inspector v2, which the scaffold installs as a dev dependency and needs Node >= 22.19.0. The Inspector is worth the bump: v2 is a real redesign, with a more modern and more intuitive UI than v1. If you are on Node 20, npm install fails on the engine check. Upgrade Node, or drop the Inspector if you plan to test another way.
Scaffold It
Passing --name puts the CLI in non-interactive mode: it takes every other option from its defaults and generates the project immediately, with no prompts to answer.
npx @agentailor/[email protected] --name=fetch-mcp-server
cd fetch-mcp-server
npm install
npm run dev
You now have a v2 server on http://localhost:3000/mcp.
Drop the --name flag if you would rather be walked through the options:
npx @agentailor/[email protected]
The defaults are what you want here, but three options are worth understanding:
--framework=sdk(the default) generates v2. If you pass--framework=fastmcpinstead, you get a v1 server. FastMCP has not migrated yet, and the template deliberately stays on v1 rather than shipping something that does not work.--templateno longer does anything for SDK projects. In v1 you chose Stateless or Stateful, and they produced genuinely different code. In v2 that distinction does not exist, so both values generate the same server. The flag is still accepted so existing scripts do not break.--stdioswitches transport. The default is streamable HTTP, which is what this guide builds. Use--stdiofor a server that runs as a local subprocess instead.
The pin to @0.7.0 keeps the generated code matching what you read here. v0.7.0 is the first release that emits v2; v0.6.2 is the last that emits v1.
What the CLI Generated, and What Changed
The session plumbing is gone
This is the headline, and it is best seen as a deletion.
Here is the real v1 entrypoint from agentailor/fetch-mcp-server, the server built in the v1 quickstart. It is a stateful server, so it carries the full session apparatus:
const app = createMcpExpressApp()
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
app.post('/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined
try {
let transport: StreamableHTTPServerTransport
if (sessionId && transports[sessionId]) {
transport = transports[sessionId] // Reuse existing transport
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId: string) => {
transports[sessionId] = transport
},
})
transport.onclose = () => {
const sid = transport.sessionId
if (sid && transports[sid]) delete transports[sid]
}
const server = getServer()
await server.connect(transport)
await transport.handleRequest(req, res, req.body)
return
} else {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
})
return
}
await transport.handleRequest(req, res, req.body)
} catch (error) {
/* hand-written 500 body */
}
})
// Plus: GET /mcp for the SSE stream, DELETE /mcp to terminate a session,
// and a SIGINT handler looping every open transport to close it.
That is roughly 150 lines. In v2, essentially all of it is deleted:
import { type Request, type Response } from 'express'
import { createMcpHandler } from '@modelcontextprotocol/server'
import { createMcpExpressApp } from '@modelcontextprotocol/express'
import { toNodeHandler } from '@modelcontextprotocol/node'
import { getServer } from './server.js'
const allowedHosts = process.env.ALLOWED_HOSTS?.split(',') ?? []
const app = createMcpExpressApp({
allowedHosts: ['localhost', '127.0.0.1', '[::1]', ...allowedHosts],
})
// The factory runs once per request, so a fresh McpServer serves every call.
const handler = createMcpHandler(() => getServer())
const node = toNodeHandler(handler)
// The handler owns method dispatch (POST/GET/DELETE) for the MCP endpoint.
app.all('/mcp', (req: Request, res: Response) => void node(req, res, req.body))
The startServer and SIGINT boilerplate below this is unchanged from v1 and omitted here for brevity. allowedHosts is a v2 host-validation guard: localhost is permitted by default, and you add your production hostnames through the ALLOWED_HOSTS environment variable.
Everything the v1 file did by hand is now inside createMcpHandler. No session map. No isInitializeRequest branching. No separate GET route for SSE, no DELETE route to terminate sessions, no SIGINT loop closing transports, no hand-written JSON-RPC error bodies. One app.all covers every method, because the handler dispatches internally.
The key line is the factory. createMcpHandler(() => getServer()) calls getServer() once per request, so a fresh McpServer serves every call and no instance holds state between requests. That is what "stateless" means concretely: any request can be served by any instance, so you can run this behind an ordinary load balancer with no session affinity, or on a scale-to-zero platform that may cold-start a new process for every call.
You can see the whole migration as a single diff: main...v2 on fetch-mcp-server. src/index.ts loses 130 lines net.
The package split
v1 shipped one package with deep subpath imports. v2 splits it into focused packages:
| v1 | v2 |
|---|---|
@modelcontextprotocol/sdk/server/mcp.js | @modelcontextprotocol/server |
@modelcontextprotocol/sdk/types.js | @modelcontextprotocol/server |
@modelcontextprotocol/sdk/server/express.js | @modelcontextprotocol/express |
@modelcontextprotocol/sdk/server/streamableHttp.js | @modelcontextprotocol/node |
The runtime dependencies in the generated package.json:
{
"dependencies": {
"@modelcontextprotocol/server": "^2.0.0",
"@modelcontextprotocol/express": "^2.0.0",
"@modelcontextprotocol/node": "^2.0.0",
"express": "^5.2.1",
"hono": "^4.11.5",
"zod": "^4.4.3"
}
}
Two notes. hono is an optional peer dependency of @modelcontextprotocol/node, which is why it appears even though no template file imports it. And the old @modelcontextprotocol/sdk package is a different package that stays on 1.x for v1 servers. Never try to pin it to 2.x; the v2 packages are the ones listed above.
Tool schemas become Standard Schema
In v1, inputSchema took a raw object of Zod validators. In v2 it takes a Standard Schema object, meaning a full z.object({ ... }):
// v1
inputSchema: {
url: z.url().describe('The URL to fetch.'),
}
// v2
inputSchema: z.object({
url: z.url().describe('The URL to fetch.'),
})
The same change applies to argsSchema on registerPrompt.
The wrapped form is what the SDK documents and what the scaffold emits, so write new code that way. In practice, though, 2.0.0 is more forgiving than the beta releases were: a raw shape still registers and still works. I verified this against 2.0.0 with zod 4.4.3: a tool declared with a raw shape appears in tools/list with a correctly generated JSON Schema and executes normally.
That matters if you are reading older migration notes. During the beta, raw shapes were reported to make tools vanish from tools/list with no error, and you will find advice written against that behavior. It does not reproduce on the stable release. Migrate to z.object({ ... }) because it is the documented, forward-compatible form, not because your tools will disappear if you don't.
Empty tools/list? Check your zod version
If your tools do go missing, or the call fails outright, the usual cause is zod 3. v2 needs zod >= 4.2.0 to convert schemas to JSON Schema.
The good news is that this is not the silent failure it was during the beta. On 2.0.0 you get an explicit, self-diagnosing error:
ProtocolError: Schema appears to be from zod 3, which the SDK cannot convert
to JSON Schema. Upgrade to zod >=4.2.0, or wrap your JSON Schema with
fromJsonSchema().
The fix is the one the message gives you:
npm install zod@^4.4.3
Worth knowing: zod 3.25 does implement the ~standard interface, so the schema object looks valid on inspection and registerTool accepts it without complaint. The failure surfaces later, when the SDK tries to produce JSON Schema for the tool listing. If you are debugging this, the version number is a more reliable signal than anything you can see on the schema object itself.
Capabilities argument removed
v1 servers declared capabilities in a second constructor argument. Since logging, sampling, and roots are deprecated in v2, that argument is gone:
// v1
const server = new McpServer(
{ name: 'my-server', version: '1.0.0' },
{ capabilities: { logging: {} } }
)
// v2
const server = new McpServer({ name: 'my-server', version: '1.0.0' })
If you relied on protocol-level logging, the replacements are stderr for stdio servers and OpenTelemetry for production observability. MCP v2: What's Changing and Why covers the reasoning behind all three deprecations.
Request context: extra becomes ctx
Tool handlers receive a context object as their second parameter. It was extra in v1 and is now ctx, restructured so that transport-specific fields are separated from protocol ones:
ctx.mcpReq.signal— the abort signal for the requestctx.http?.authInfo— auth info, optional because a stdio server has no HTTP request behind it
The ?. matters. Read http defensively unless your server is HTTP-only.
AGENT BRIEFINGS
What actually matters for building and scaling AI agents in production — and what's just hype. Straight from the work, no filler.
Build the Fetch Tool
Let's write a real tool: fetch, which retrieves a URL and converts the HTML to markdown. This is deliberately the same example as the v1 quickstart, so you can compare the two directly.
Install the HTML converter:
npm install node-html-markdown
Replace src/server.ts:
import type { CallToolResult, GetPromptResult } from '@modelcontextprotocol/server'
import { McpServer } from '@modelcontextprotocol/server'
import { z } from 'zod'
import { NodeHtmlMarkdown } from 'node-html-markdown'
const DEFAULT_USER_AGENT = 'FetchMCPServer/1.0 (+https://github.com/agentailor/fetch-mcp-server)'
export function getServer() {
const server = new McpServer({
name: 'fetch-mcp-server',
version: '1.0.0',
})
server.registerTool(
'fetch',
{
description: `Fetches a URL from the internet and extracts its contents as markdown.
Use this tool when you need to:
- Read documentation or articles from the web
- Get current information from websites
- Access publicly available web content
The HTML content is automatically converted to clean markdown for easier reading.`,
inputSchema: z.object({
url: z.url().describe('The URL to fetch. Must be a valid HTTP or HTTPS URL.'),
max_length: z
.number()
.int()
.positive()
.max(100000)
.default(5000)
.describe('Maximum number of characters to return. Defaults to 5000.'),
start_index: z
.number()
.int()
.min(0)
.default(0)
.describe(
'Start content from this character index. Use for pagination when content is truncated.'
),
raw: z
.boolean()
.default(false)
.describe('If true, returns raw HTML instead of converting to markdown.'),
}),
},
async ({ url, max_length = 5000, start_index = 0, raw = false }): Promise<CallToolResult> => {
try {
const response = await fetch(url, {
headers: { 'User-Agent': DEFAULT_USER_AGENT },
redirect: 'follow',
})
if (!response.ok) {
return {
content: [
{
type: 'text',
text: `Failed to fetch ${url}: HTTP ${response.status} ${response.statusText}`,
},
],
isError: true,
}
}
const contentType = response.headers.get('content-type') || ''
const html = await response.text()
const isHtml =
contentType.includes('text/html') ||
html.trim().toLowerCase().startsWith('<!doctype') ||
html.trim().toLowerCase().startsWith('<html')
let content: string
let prefix = ''
if (isHtml && !raw) {
content = NodeHtmlMarkdown.translate(html)
} else {
content = html
if (!isHtml) prefix = `Content-Type: ${contentType}\n\n`
}
const originalLength = content.length
if (start_index >= originalLength) {
return {
content: [
{
type: 'text',
text: `No more content available. Total length: ${originalLength} characters.`,
},
],
}
}
const truncatedContent = content.slice(start_index, start_index + max_length)
const remaining = originalLength - (start_index + truncatedContent.length)
let result = `${prefix}Contents of ${url}:\n\n${truncatedContent}`
if (remaining > 0) {
const nextIndex = start_index + truncatedContent.length
result += `\n\n---\n[Content truncated. ${remaining} characters remaining. Use start_index=${nextIndex} to continue.]`
}
return { content: [{ type: 'text', text: result }] }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
content: [{ type: 'text', text: `Error fetching ${url}: ${errorMessage}` }],
isError: true,
}
}
}
)
server.registerPrompt(
'fetch',
{
description: 'Fetch a URL and extract its contents as markdown',
argsSchema: z.object({
url: z.url().describe('URL to fetch'),
}),
},
async ({ url }): Promise<GetPromptResult> => {
return {
messages: [
{
role: 'user',
content: { type: 'text', text: `Please fetch and summarize the content from: ${url}` },
},
],
}
}
)
return server
}
Compare this against the v1 version and the tool logic is identical. Only three things moved: the import source, the z.object({ ... }) wrappers, and the dropped capabilities argument. Your business logic does not care that the protocol went stateless, which is the point.
Test with MCP Inspector
Start the server, then in a second terminal:
npm run inspect
Set the transport to HTTP, point it at http://localhost:3000/mcp, and click Connect. Then Tools → List Tools, pick fetch, fill in a URL, and run it.
NOTE
The inspect script hardcodes port 3000. If port 3000 was busy, the server falls back to a random port (check the startup log for the real one), and the script points at the wrong place. Either update the URL in package.json, or run npx mcp-inspector with no arguments and type the correct URL into the Inspector's own address field.
The Inspector also has a CLI mode, which is faster for a smoke test and easy to script:
npx mcp-inspector --cli http://localhost:3000/mcp --transport http --method tools/list
npx mcp-inspector --cli http://localhost:3000/mcp --transport http \
--method tools/call --tool-name fetch --tool-arg url=https://example.com
A healthy tools/list returns your tool with a fully-formed JSON Schema. If the tool is missing or the schema looks empty, revisit the zod section above.
For the general click-through walkthrough, see the v1 quickstart's testing section. The steps are the same, though the screenshots there show Inspector v1: the scaffold now installs Inspector v2, whose UI is more modern and noticeably more intuitive to navigate.
Migrating an Existing v1 Server
If you have a v1 server already, the migration is mechanical. In order:
- Swap the dependencies. Remove
@modelcontextprotocol/sdk, add@modelcontextprotocol/server,/express, and/node, plushono. - Make sure zod is >= 4.2.0.
- Rewrite the entrypoint. Delete the session map, the
GETandDELETEroutes, the SIGINT transport loop, and the manual error bodies. Replace withcreateMcpHandler+toNodeHandler+ oneapp.all. - Update
server.ts. Change the imports, wrap schemas inz.object({ ... }), drop the capabilities argument. - Rename
extratoctxin any handler that used it, and readctx.http?.authInfodefensively.
Steps 3 and 4 are the whole job, and step 3 is mostly deleting code. main...v2 on fetch-mcp-server is exactly this migration on a real server if you want a reference diff.
One planning note: v1 is not going anywhere yet. The deprecated subsystems have a one-year grace period, so v1 servers in production keep working. Migrate deliberately, not urgently.
Conclusion
MCP v2 asks less of you than v1 did. The protocol went stateless, and the SDK turned that into roughly 150 lines of session management you no longer write. What remains is your tools.
The complete v2 server from this guide is on GitHub: agentailor/fetch-mcp-server (v2 branch).
Resources
- fetch-mcp-server, v2 branch — the finished server from this guide
- main...v2 compare view — the full v1 to v2 migration as one diff
- create-mcp-server — the CLI used here
- TypeScript SDK v2 Migration Guide — the official upgrade path
- MCP Specification — the full protocol spec
Related Articles
- MCP v2: What's Changing, What's Deprecated, and Why — the conceptual companion to this guide
- Create Your First MCP Server in 5 Minutes — the v1 quickstart this guide mirrors
- The MCP TypeScript SDK: A Complete Guide — the v1 SDK deep dive
- Securing MCP Servers with OAuth and Keycloak — adding authentication
AGENT BRIEFINGS
Stay measured as the field moves.
What actually matters for building and scaling AI agents in production — and what's just hype. Straight from the work, no filler.
$ subscribe agent-briefings
→ what works in production, what doesn't.
→ frameworks, MCP, evals, managed services.
→ signal over hype.