Building an MCP Server with the TypeScript SDK
The official TypeScript MCP SDK mirrors the Python SDK's shape: register tool and resource handlers on a server object, then run it over a transport.
If your service already lives in a Node codebase, this SDK lets you expose it to Claude without introducing a second language.
Summary
A TypeScript MCP server is built around McpServer, the SDK's high-level server class.
You register capabilities by calling server.tool(...) and server.resource(...) directly, rather than with decorators.
Zod schemas define each tool's input shape and double as runtime validation.
The server connects to a transport, most commonly StdioServerTransport for local development, the same way the Python SDK does.
This page mirrors the depth of the Python SDK article: typed arguments, error handling, and parameterized resources, expressed in idiomatic TypeScript.
Recipe
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: "tickets-server", version: "1.0.0" });
server.tool(
"create_ticket",
{ title: z.string(), priority: z.enum(["low", "normal", "high"]).default("normal") },
async ({ title, priority }) => ({
content: [{ type: "text", text: `Created ticket for "${title}" (${priority})` }],
})
);
await server.connect(new StdioServerTransport());When to reach for this:
- Your existing service or team is already TypeScript/Node-based, and you want to avoid a second runtime.
- You want compile-time type checking on tool arguments alongside runtime validation from the same schema.
- You're building for local development first,
StdioServerTransportis the right choice before adding a network layer. - You want to share types between your MCP server and an existing TypeScript API surface.
Working Example
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
interface Ticket {
id: string;
title: string;
priority: "low" | "normal" | "high";
status: "open" | "closed";
}
const tickets = new Map<string, Ticket>([
["T-1", { id: "T-1", title: "Printer offline", priority: "low", status: "open" }],
["T-2", { id: "T-2", title: "VPN outage", priority: "high", status: "closed" }],
]);
let nextId = 3;
const server = new McpServer({ name: "tickets-server", version: "1.0.0" });
server.tool(
"create_ticket",
{
title: z.string().min(1, "title is required"),
priority: z.enum(["low", "normal", "high"]).default("normal"),
},
async ({ title, priority }) => {
const id = `T-${nextId++}`;
tickets.set(id, { id, title, priority, status: "open" });
return { content: [{ type: "text", text: id }] };
}
);
server.tool(
"close_ticket",
{ ticketId: z.string() },
async ({ ticketId }) => {
const ticket = tickets.get(ticketId);
if (!ticket) {
throw new Error(`no ticket with id '${ticketId}'`);
}
ticket.status = "closed";
return { content: [{ type: "text", text: `${ticketId} closed` }] };
}
);
server.resource(
"ticket",
"tickets://{ticketId}",
async (uri, { ticketId }) => {
const ticket = tickets.get(ticketId as string);
if (!ticket) {
throw new Error(`no ticket with id '${ticketId}'`);
}
return {
contents: [
{
uri: uri.href,
text: `${ticket.id} [${ticket.priority}] ${ticket.title} - ${ticket.status}`,
},
],
};
}
);
server.resource("open-tickets", "tickets://open", async (uri) => {
const openIds = [...tickets.values()].filter((t) => t.status === "open").map((t) => t.id);
return { contents: [{ uri: uri.href, text: openIds.join("\n") || "no open tickets" }] };
});
await server.connect(new StdioServerTransport());What this demonstrates:
- Two tools,
create_ticketandclose_ticket, with Zod schemas that enforce both the type and the allowed values ofpriority. - A parameterized resource,
tickets://{ticketId}, next to a fixed resource,tickets://open, mirroring the two resource styles from the Python example. - Thrown
Errorobjects used for validation failures, converted by the SDK into structured error responses. - The tool's return shape,
{ content: [...] }, which is the TypeScript SDK's structured result format.
Deep Dive
How It Works
new McpServer({ name, version })creates a server instance and wires up the connection and negotiation stages for you, identically toFastMCPin Python.server.tool(name, schema, handler)registers a tool undername, using the Zodschemaobject to build the JSON schema sent to clients during negotiation.server.resource(name, uriTemplate, handler)registers a resource; a{placeholder}segment in the URI template is passed to the handler as a parameter.- The SDK validates incoming tool arguments against the Zod schema before invoking your handler, so malformed input never reaches your code.
server.connect(transport)starts the server against a chosen transport,StdioServerTransportfor local development, an HTTP-based transport for remote deployment.
Error Handling
Throwing a standard Error inside a handler is the correct way to signal failure.
server.tool("divide", { a: z.number(), b: z.number() }, async ({ a, b }) => {
if (b === 0) {
throw new Error("cannot divide by zero");
}
return { content: [{ type: "text", text: String(a / b) }] };
});The SDK catches the thrown error and returns a structured error result to the client instead of crashing the process.
Avoid returning a success-shaped result with an error message embedded in the text, the client has no reliable way to distinguish that from a real result.
Tools vs Resources at a Glance
| Capability | Registered With | Has Side Effects | Example |
|---|---|---|---|
| Tool | server.tool(name, schema, handler) | Often yes | create_ticket, close_ticket |
| Resource | server.resource(name, uri, handler) | No | tickets://open, tickets://{ticketId} |
TypeScript Notes
// Zod schemas double as compile-time types via z.infer.
const CreateTicketInput = z.object({
title: z.string(),
priority: z.enum(["low", "normal", "high"]).default("normal"),
});
type CreateTicketInput = z.infer<typeof CreateTicketInput>;Defining the schema once with Zod and deriving the TypeScript type from it keeps the runtime validation and the compile-time type in sync, so a schema change can't silently drift from the type your handler expects.
Parameters & Return Values
| Method | Registers | Handler Return Shape |
|---|---|---|
server.tool(name, schema, handler) | A callable action | { content: [{ type: "text", text: string }] } |
server.resource(name, uri, handler) | Read-only content at a URI | { contents: [{ uri: string, text: string }] } |
Gotchas
- Using a loose Zod type like
z.string()for an enum-like value. This lets invalid values likepriority: "urgent"through without a schema-level rejection. Fix: usez.enum([...])for any argument with a fixed set of valid values. - Returning a plain string from a tool handler instead of the content shape. The SDK expects
{ content: [...] }, not a bare string. Fix: always wrap the result in the structured content array, even for a simple text response. - Forgetting
awaitonserver.connect(...). The process can exit before the transport finishes attaching, making the server look like it silently does nothing. Fix: alwaysawait server.connect(transport)at the top level. - Mutating a module-level
Mapwithout guarding for concurrent sessions. Under HTTP/SSE with multiple clients, overlapping tool calls can race on shared in-memory state. Fix: back real state with a database once you move past local prototyping. - Naming a tool something generic like
runorexecute. Vague names give Claude little signal about what the tool actually does, especially once a server has several tools. Fix: name tools after the specific action they perform,create_ticketnotrun. - Skipping
.min()or format validation on string inputs. An emptytitlestring passes a barez.string()check and can silently create malformed records. Fix: add the specific Zod refinements (.min(1),.email(), etc.) the field actually needs.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Python MCP SDK | Your team or existing stack is Python-based, or you want to reuse an existing data/ML codebase | You want to share types directly with an existing TypeScript frontend or API |
Low-level MCP protocol classes (no McpServer) | You need fine-grained control the high-level API doesn't expose | Standard tools and resources cover your needs, which is most of the time |
| A plain REST API (no MCP) | The consumer isn't an LLM client and doesn't need capability negotiation | You want Claude to discover and call your functionality dynamically |
FAQs
How is McpServer different from FastMCP in the Python SDK?
- Both are the high-level server class for their respective SDK.
FastMCPuses decorators (@mcp.tool());McpServeruses direct method calls (server.tool(...)).- Both handle connection and capability negotiation automatically and expose the same tool/resource/prompt concepts.
Why does this SDK use Zod instead of plain TypeScript types?
TypeScript types are erased at runtime, so they can't validate incoming data on their own. Zod schemas provide both a runtime validator and, via z.infer, a matching compile-time type from the same definition.
What happens if a tool handler throws an error?
server.tool("risky", { n: z.number() }, async ({ n }) => {
if (n < 0) throw new Error("n must be non-negative");
return { content: [{ type: "text", text: String(n) }] };
});The SDK catches the thrown error and returns a structured error response to the client instead of crashing the server process.
Can a resource handler take multiple parameters from its URI?
Yes. A URI template can include more than one {placeholder} segment, and each one is passed to the handler, the same way a tool's schema fields are passed as arguments.
Should tool arguments always have defaults?
No, only when a sensible default genuinely exists, like priority defaulting to "normal". Required fields, like title in the ticket example, should stay required so the schema rejects incomplete calls.
What transport should I use during local development?
StdioServerTransport. It's the standard local transport, the client spawns your server as a subprocess and talks to it over stdin/stdout, no network setup required.
How do I test a TypeScript MCP server without a real client?
Write unit tests that call your handler functions directly with sample arguments, then separately exercise them through a mock client that speaks the protocol. See the dedicated testing page for the full pattern.
Can I register both a tool and a resource with the same underlying data?
Yes, and it's a common pattern. A tool that mutates a record (close_ticket) and a resource that reads it (tickets://{ticketId}) can share the same in-memory store or database layer.
Does the SDK support async handlers?
Yes, handlers are expected to be async functions by convention, since most real tools and resources involve I/O like a database call or an API request.
What's the return shape a tool handler must produce?
An object with a content array, each entry typically { type: "text", text: string }. Returning a bare string or object without this wrapper will not match what the client expects.
How do I avoid breaking existing clients when I change a tool's schema?
Add new fields as optional with .optional() or a sensible .default(...) rather than renaming or removing existing fields. See the dedicated page on versioning tool schemas for the broader strategy.
Is McpServer specific to Node.js, or does it also run in browsers?
The SDK targets Node.js for server-side use, since MCP servers are typically long-running processes with access to a transport like stdio or a network socket, neither of which is available in a browser context.
Related
- MCP Server Basics - the minimal skeleton this article builds on
- Building an MCP Server with the Python SDK - the same patterns in Python
- How MCP Servers Handle Requests - the lifecycle these handlers plug into
- Testing MCP Servers Before Deployment - exercising these handlers with a mock client
- stdio vs HTTP/SSE MCP Deployment Comparison - choosing a transport beyond local development
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the current Model Context Protocol Python/TypeScript SDKs. Model names, SDK versions, and the MCP spec move quickly - verify current specifics at platform.claude.com/docs and modelcontextprotocol.io before relying on them.