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.
Search across all documentation pages
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.
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.
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:
StdioServerTransport is the right choice before adding a network layer.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:
create_ticket and close_ticket, with Zod schemas that enforce both the type and the allowed values of priority.tickets://{ticketId}, next to a fixed resource, tickets://open, mirroring the two resource styles from the Python example.Error objects used for validation failures, converted by the SDK into structured error responses.{ content: [...] }, which is the TypeScript SDK's structured result format.new McpServer({ name, version }) creates a server instance and wires up the connection and negotiation stages for you, identically to FastMCP in Python.server.tool(name, schema, handler) registers a tool under name, using the Zod schema object 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.server.connect(transport) starts the server against a chosen transport, StdioServerTransport for local development, an HTTP-based transport for remote deployment.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.
| 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} |
// 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.
| 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 }] } |
z.string() for an enum-like value. This lets invalid values like priority: "urgent" through without a schema-level rejection. Fix: use z.enum([...]) for any argument with a fixed set of valid values.{ content: [...] }, not a bare string. Fix: always wrap the result in the structured content array, even for a simple text response.await on server.connect(...). The process can exit before the transport finishes attaching, making the server look like it silently does nothing. Fix: always await server.connect(transport) at the top level.Map without 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.run or execute. 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_ticket not run..min() or format validation on string inputs. An empty title string passes a bare z.string() check and can silently create malformed records. Fix: add the specific Zod refinements (.min(1), .email(), etc.) the field actually needs.| 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 |
FastMCP uses decorators (@mcp.tool()); McpServer uses direct method calls (server.tool(...)).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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Reviewed by Chris St. John·Last updated Jul 18, 2026