This guide covers SDK usage for building MCP servers in TypeScript. For protocol-level details and message formats, see the MCP specification.
Building a server takes three steps:
McpServer and register your tools, resources, and prompts.server.connect(transport).The sections below cover each of these. For a feature‑rich starting point, see simpleStreamableHttp.ts — remove what you don't need and register your own tools, resources, and prompts. For stateless or JSON‑response‑mode alternatives, see the examples linked in Transports below.
Streamable HTTP is the HTTP‑based transport. It supports:
A minimal stateful setup:
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
await server.connect(transport);
For full runnable examples, see simpleStreamableHttp.ts (sessions, logging, tasks, elicitation, auth hooks), jsonResponseStreamableHttp.ts (enableJsonResponse: true, no SSE), and standaloneSseWithGetStreamableHttp.ts (notifications with Streamable HTTP GET + SSE).
For protocol details, see Transports in the MCP specification.
Streamable HTTP can run:
The key difference is the sessionIdGenerator option. Pass undefined for stateless mode:
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await server.connect(transport);
For full runnable examples, see simpleStatelessStreamableHttp.ts (stateless) and simpleStreamableHttp.ts (stateful with resumability).
If you do not need SSE streaming, set enableJsonResponse: true. The server will return plain JSON responses to every POST and reject GET requests with 405:
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true
});
await server.connect(transport);
For a full runnable example, see jsonResponseStreamableHttp.ts.
For local, process‑spawned integrations (Claude Desktop, CLI tools), use StdioServerTransport:
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
const transport = new StdioServerTransport();
await server.connect(transport);
Tools let MCP clients ask your server to take actions. They are usually the main way that LLMs call into your application.
A typical registration with registerTool:
server.registerTool(
'calculate-bmi',
{
title: 'BMI Calculator',
description: 'Calculate Body Mass Index',
inputSchema: z.object({
weightKg: z.number(),
heightM: z.number()
}),
outputSchema: z.object({ bmi: z.number() })
},
async ({ weightKg, heightM }) => {
const output = { bmi: weightKg / (heightM * heightM) };
return {
content: [{ type: 'text', text: JSON.stringify(output) }],
structuredContent: output
};
}
);
For full runnable examples, see simpleStreamableHttp.ts and toolWithSampleServer.ts.
For protocol details, see Tools in the MCP specification.
ResourceLink outputsTools can return resource_link content items to reference large resources without embedding them directly, allowing clients to fetch only what they need:
server.registerTool(
'list-files',
{
title: 'List Files',
description: 'Returns files as resource links without embedding content'
},
async (): Promise<CallToolResult> => {
const links: ResourceLink[] = [
{
type: 'resource_link',
uri: 'file:///projects/readme.md',
name: 'README',
mimeType: 'text/markdown'
},
{
type: 'resource_link',
uri: 'file:///projects/config.json',
name: 'Config',
mimeType: 'application/json'
}
];
return { content: links };
}
);
For a full runnable example with ResourceLink outputs, see simpleStreamableHttp.ts.
Tools can include annotations that hint at their behavior — for example, whether a tool is read‑only, destructive, or idempotent. Annotations help clients present tools appropriately without changing their execution semantics.
For tool annotations in a full server, see simpleStreamableHttp.ts.
Resources expose data to clients, but should not perform heavy computation or side‑effects. They are ideal for configuration, documents, or other reference data.
A static resource at a fixed URI:
server.registerResource(
'config',
'config://app',
{
title: 'Application Config',
description: 'Application configuration data',
mimeType: 'text/plain'
},
async uri => ({
contents: [{ uri: uri.href, text: 'App configuration here' }]
})
);
Dynamic resources use ResourceTemplate and can support completions on path parameters:
server.registerResource(
'user-profile',
new ResourceTemplate('user://{userId}/profile', {
list: async () => ({
resources: [
{ uri: 'user://123/profile', name: 'Alice' },
{ uri: 'user://456/profile', name: 'Bob' }
]
})
}),
{
title: 'User Profile',
description: 'User profile data',
mimeType: 'application/json'
},
async (uri, { userId }) => ({
contents: [
{
uri: uri.href,
text: JSON.stringify({ userId, name: 'Example User' })
}
]
})
);
For full runnable examples of resources, see simpleStreamableHttp.ts.
For protocol details, see Resources in the MCP specification.
Prompts are reusable templates that help humans (or client UIs) talk to models in a consistent way. They are declared on the server and listed through MCP.
A minimal prompt:
server.registerPrompt(
'review-code',
{
title: 'Code Review',
description: 'Review code for best practices and potential issues',
argsSchema: z.object({
code: z.string()
})
},
({ code }) => ({
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: `Please review this code:\n\n${code}`
}
}
]
})
);
For prompts integrated into a full server, see simpleStreamableHttp.ts.
For protocol details, see Prompts in the MCP specification.
Both prompts and resources can support argument completions. Wrap a field in the argsSchema with completable() to provide autocompletion suggestions:
server.registerPrompt(
'review-code',
{
title: 'Code Review',
description: 'Review code for best practices',
argsSchema: z.object({
language: completable(z.string().describe('Programming language'), value =>
['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value))
)
})
},
({ language }) => ({
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: `Review this ${language} code for best practices.`
}
}
]
})
);
Unlike tools, resources, and prompts, logging is not a registered primitive — it is a handler-level API available inside any callback. Use ctx.mcpReq.log(level, data) (from ServerContext) to send structured log messages to the client. The server must declare the logging capability:
const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } });
Then log from any handler callback:
server.registerTool(
'fetch-data',
{
description: 'Fetch data from an API',
inputSchema: z.object({ url: z.string() })
},
async ({ url }, ctx): Promise<CallToolResult> => {
await ctx.mcpReq.log('info', `Fetching ${url}`);
const res = await fetch(url);
await ctx.mcpReq.log('debug', `Response status: ${res.status}`);
const text = await res.text();
return { content: [{ type: 'text', text }] };
}
);
For logging in a full server, see simpleStreamableHttp.ts and jsonResponseStreamableHttp.ts.
For protocol details, see Logging in the MCP specification.
MCP is bidirectional — servers can also send requests to the client during tool execution, as long as the client declares matching capabilities.
Use ctx.mcpReq.requestSampling(params) (from ServerContext) inside a tool handler to request an LLM completion from the connected client:
server.registerTool(
'summarize',
{
description: 'Summarize text using the client LLM',
inputSchema: z.object({ text: z.string() })
},
async ({ text }, ctx): Promise<CallToolResult> => {
const response = await ctx.mcpReq.requestSampling({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please summarize:\n\n${text}`
}
}
],
maxTokens: 500
});
return {
content: [
{
type: 'text',
text: `Model (${response.model}): ${JSON.stringify(response.content)}`
}
]
};
}
);
For a full runnable example, see toolWithSampleServer.ts.
For protocol details, see Sampling in the MCP specification.
Use ctx.mcpReq.elicitInput(params) (from ServerContext) inside a tool handler to request user input. Elicitation supports two modes:
mode: 'form') — collects non‑sensitive data via a schema‑driven form.mode: 'url') — for sensitive data or secure web‑based flows (API keys, payments, OAuth). The client opens a URL in the browser.Sensitive information must not be collected via form elicitation; always use URL elicitation or out‑of‑band flows for secrets.
server.registerTool(
'collect-feedback',
{
description: 'Collect user feedback via a form',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult> => {
const result = await ctx.mcpReq.elicitInput({
mode: 'form',
message: 'Please share your feedback:',
requestedSchema: {
type: 'object',
properties: {
rating: {
type: 'number',
title: 'Rating (1\u20135)',
minimum: 1,
maximum: 5
},
comment: { type: 'string', title: 'Comment' }
},
required: ['rating']
}
});
if (result.action === 'accept') {
return {
content: [
{
type: 'text',
text: `Thanks! ${JSON.stringify(result.content)}`
}
]
};
}
return { content: [{ type: 'text', text: 'Feedback declined.' }] };
}
);
For runnable examples, see elicitationFormExample.ts (form mode) and elicitationUrlExample.ts (URL mode).
For protocol details, see Elicitation in the MCP specification.
Task-based execution enables "call-now, fetch-later" patterns for long-running operations. Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. To use tasks:
TaskStore implementation that persists task metadata and results (see InMemoryTaskStore for reference).tasks capability when constructing the server.server.experimental.tasks.registerToolTask(...).
For a full runnable example, see simpleTaskInteractive.ts.
The tasks API is experimental and may change without notice.
MCP servers running on localhost are vulnerable to DNS rebinding attacks. Use createMcpExpressApp() from @modelcontextprotocol/express to create an Express app with DNS rebinding protection enabled by default:
// Default: DNS rebinding protection auto-enabled (host is 127.0.0.1)
const app = createMcpExpressApp();
// DNS rebinding protection also auto-enabled for localhost
const appLocal = createMcpExpressApp({ host: 'localhost' });
// No automatic protection when binding to all interfaces
const appOpen = createMcpExpressApp({ host: '0.0.0.0' });
When binding to 0.0.0.0 / ::, provide an allow-list of hosts:
const app = createMcpExpressApp({
host: '0.0.0.0',
allowedHosts: ['localhost', '127.0.0.1', 'myhost.local']
});
The sections above cover the essentials. The table below links to additional capabilities demonstrated in the runnable examples.
| Feature | Description | Reference |
|---|---|---|
| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | honoWebStandardStreamableHttp.ts |
| Session management | Per-session transport routing, initialization, and cleanup | simpleStreamableHttp.ts |
| Resumability | Replay missed SSE events via an event store | inMemoryEventStore.ts |
| CORS | Expose MCP headers (mcp-session-id, etc.) for browser clients |
simpleStreamableHttp.ts |
| Multi‑node deployment | Stateless, persistent‑storage, and distributed routing patterns | examples/server/README.md |