How to build an MCP server in TypeScript: step-by-step
To build an MCP server in TypeScript, pick one narrow capability, register it as a tool with a Zod input schema, serve it over stdio for local use or Streamable HTTP for a hosted endpoint, test every tool with MCP Inspector, and enforce authorization inside your own handlers rather than trusting the calling model.
The protocol part is small. Most of the work is deciding what to expose, describing it well enough that a model picks the right tool, and making sure a wrong call cannot do damage.
Decide what the server is for first
An MCP server is a boundary around a capability the model does not otherwise have. Before writing code, answer three questions:
- What can this server do that the model cannot already do? Reading files it has access to, or restating knowledge it has, is not a capability worth a server.
- What is the smallest set of operations that covers the job? Three well-named tools beat fifteen vague ones. Every tool definition sits in the model's context whether or not it gets used.
- What is the worst call someone could make? That answer determines your authorization model, not the happy path.
If the job is really "teach the model our conventions" rather than "give the model a new power," an agent skill is the cheaper choice. If MCP itself is new, start with what an MCP server is.
Set up the project
The current TypeScript SDK ships as @modelcontextprotocol/server and is ES-modules only, so the
package must declare type: module. Node.js 20 or later is required.
mkdir catalog-server && cd catalog-server
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node tsx
mkdir src
tsx runs TypeScript directly during development, so there is no build step until you package the
server for distribution.
Register your first tool
Build the server inside a factory function that returns a fresh McpServer. Both transports take
a factory rather than a pre-built instance, so writing it this way from the start means the same code
serves stdio and HTTP without restructuring.
// src/index.ts
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const CATALOG_API = 'https://api.example.com/v1';
export function createServer(): McpServer {
const server = new McpServer({ name: 'catalog', version: '1.0.0' });
server.registerTool(
'search_products',
{
description: 'Search the product catalogue by name or SKU fragment',
inputSchema: z.object({
query: z.string().min(2).describe('Substring to match against product names and SKUs'),
limit: z.number().int().min(1).max(50).optional().describe('Maximum results, default 10'),
}),
},
async ({ query, limit }) => {
const url = `${CATALOG_API}/products?q=${encodeURIComponent(query)}&limit=${limit ?? 10}`;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) {
return {
content: [{ type: 'text', text: `Catalogue API error: HTTP ${res.status}` }],
isError: true,
};
}
const { products } = (await res.json()) as { products: { sku: string; name: string }[] };
if (products.length === 0) {
return { content: [{ type: 'text', text: `No products matched "${query}".` }] };
}
return {
content: [{ type: 'text', text: products.map(p => `${p.sku} ${p.name}`).join('\n') }],
};
},
);
return server;
}
registerTool takes a name, a config object, and an async handler. The Zod schema is the only schema
you write: the SDK derives the JSON Schema advertised in tools/list, validates arguments before your
handler runs, and infers the handler's argument types.
Argument descriptions matter more than they look. .describe() text survives the conversion to JSON
Schema and is the only documentation the model gets for that argument.
Write tool descriptions for a model, not a changelog
A tool is selected by a model reading its name and description. Treat that text as the interface.
| Weak | Better |
|---|---|
search — "Searches." |
search_products — "Search the product catalogue by name or SKU fragment. Returns SKU and name only." |
update — "Updates a record." |
update_stock_level — "Set the on-hand quantity for one SKU. Overwrites the current value." |
Name the object the tool acts on, say what it returns, and say plainly when it writes. A model that picks the wrong tool is usually reading an accurate description of the wrong thing.
Return results the model can actually use
A tool result is a list of typed content blocks: text, image, audio, resource_link, or an
embedded resource. One result can mix them.
When a caller needs machine-readable output, add an outputSchema and return structuredContent
alongside the human-readable content. The SDK validates the structured value against the schema
before the result leaves your server and advertises the derived schema in tools/list so clients can
validate it too.
server.registerTool(
'get_product',
{
description: 'Look up one product by its exact SKU',
inputSchema: z.object({ sku: z.string() }),
outputSchema: z.object({ sku: z.string(), name: z.string(), price: z.number() }),
},
async ({ sku }) => {
const product = await fetchProduct(sku);
return {
content: [{ type: 'text', text: `${product.name} — ${product.price}` }],
structuredContent: product,
};
},
);
Keep results small. Paginate, truncate, and summarise on the server side. A tool that returns a 10,000-row dump burns the caller's context window and usually makes the model worse at the task.
Separate the two kinds of error
MCP distinguishes protocol errors from tool execution errors, and the distinction changes what the model does next.
- Protocol errors are JSON-RPC errors: unknown tool, malformed request, internal failure. Clients may show them to the model, but they rarely lead to a successful retry.
- Tool execution errors are ordinary results with
isError: true. Clients pass these to the model so it can self-correct — a bad date format, an out-of-range value, a business-rule rejection.
Schema violations already take the second path for you: the SDK rejects arguments that fail the Zod
schema and returns an isError: true result explaining what was wrong, without ever running your
handler. Write your own failure messages to the same standard. "Invalid departure date: must be in the future. Today is 2026-09-08." is actionable; "Bad request" is not.
Mark behaviour with annotations
Annotations are hints clients use to decide what to put in front of a person. They never change how the SDK runs your tool, but a host can auto-approve a read-only tool and require confirmation before a destructive one.
server.registerTool(
'delete_product',
{
title: 'Delete a product',
description: 'Permanently remove one SKU from the catalogue',
inputSchema: z.object({ sku: z.string() }),
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
},
async ({ sku }) => { /* ... */ },
);
Annotate honestly. A destructive tool labelled read-only is a defect with a security impact, not a cosmetic one.
Serve it over stdio for local use
When a host launches your server as a local child process, serve the factory over stdio.
// src/stdio.ts
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './index.js';
void serveStdio(createServer);
console.error('catalog MCP server running on stdio');
stdout is the protocol channel. A single console.log corrupts the JSON-RPC stream and breaks the
connection. Log to console.error, or to a file. This is the most common first bug in a stdio server,
and it presents as an unexplained disconnect rather than as a log-related error.
Run it with npx tsx src/stdio.ts. Nothing will appear to happen: a stdio server waits on stdin until
a client starts the conversation.
Serve it over HTTP for a hosted endpoint
To host one endpoint many clients connect to, use createMcpHandler.
// src/http.ts
import { createMcpHandler } from '@modelcontextprotocol/server';
import { createServer } from './index.js';
const handler = createMcpHandler(createServer);
export default handler;
handler.fetch is a web-standard (Request) => Promise<Response>. On Cloudflare Workers, Deno, or
Bun, export default handler is the entire mount. Under a Node framework, wrap it once with
toNodeHandler from @modelcontextprotocol/node.
Two properties of this handler are worth understanding before you deploy it.
The factory runs once per request. A fresh server instance serves every request and the handler holds nothing between requests, so the endpoint is stateless and scales horizontally as-is. Register tools inside the factory, never on a shared instance outside it. Create connection pools and caches once at module scope and close over them.
The handler trusts its caller. It validates no Host header, no Origin header, and no token.
Those checks belong in front of it:
import { createServer as createHttpServer } from 'node:http';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation }
from '@modelcontextprotocol/node';
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createHttpServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(3000, '127.0.0.1');
The Host check is what stops DNS rebinding: a malicious page resolving its own domain to
127.0.0.1 so the browser treats your local server as same-origin. The framework app factories
(createMcpExpressApp, createMcpHonoApp, createMcpFastifyApp) arm both checks by default on
localhost binds, so you only wire them by hand on plain node:http.
Bind to 127.0.0.1 rather than 0.0.0.0 for anything running on a developer machine.
Handle authentication as pass-through
authInfo is exactly that: pass-through. The handler never reads a token from headers and never
verifies one. Verify the bearer token in front of the handler and hand the result to
handler.fetch(request, { authInfo }). The factory reads it back as authInfo, and tool handlers as
ctx.http.authInfo.
const perCaller = createMcpHandler(({ authInfo }) => {
const server = new McpServer({ name: 'catalog', version: '1.0.0' });
// register only the tools this caller's scopes permit
return server;
});
Two rules follow from the specification, and both are your responsibility rather than the SDK's:
- Validate the audience. Accept only tokens issued for your server. A token minted for a different resource must be rejected even when the signature checks out.
- Enforce scope in the handler. The advertised tool list may legitimately vary by the caller's granted scopes, but hiding a tool is not access control. Check authorization inside the handler that performs the action.
For the full authorization picture — discovery, PKCE, resource indicators, step-up scopes — see MCP authentication explained.
Carry state with explicit handles
MCP has no protocol-level session in the current revision, so a server cannot rely on per-connection state to relate one tool call to the next. If a workflow genuinely needs state — a cart, a browser context, an open transaction — return an explicit handle from a creation tool and accept it as an argument on later calls.
// → tools/call { "name": "create_basket", "arguments": {} }
// ← result { "structuredContent": { "basket_id": "bsk_a1b2c3" } }
// → tools/call { "name": "add_item", "arguments": { "basket_id": "bsk_a1b2c3", "sku": "..." } }
Make the handle opaque, give it a bounded lifetime, state that lifetime in the creation tool's description so the model can see it, and — for an authenticated server — validate the caller's authorization against the handle on every call. A handle is a name, not a capability.
Test every tool before a model ever sees it
MCP Inspector connects to your server directly, so you can exercise each tool without hoping a model picks it.
# Launch the web UI against your stdio server
npx @modelcontextprotocol/inspector npx tsx src/stdio.ts
# Or check the tool list from a script
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts --method tools/list
Run four checks on every tool: a valid call, a call that violates the schema, a call for a record that does not exist, and a call the caller is not authorized to make. The last two are where most servers leak information through error messages.
The full workflow, including CI usage and exit codes, is in how to test an MCP server with MCP Inspector.
Before you ship
- Every tool input is validated by a schema, and the handler never trusts a value it did not validate.
- No secrets appear in tool descriptions, results, or error text.
- Destructive tools are annotated as destructive and require authorization, not just approval.
- Results are bounded in size and paginated where the underlying data is not.
- stdio servers write nothing but MCP messages to stdout.
- HTTP servers validate
HostandOrigin, bind to loopback locally, and validate token audience. - Rate limits exist on anything that reaches an external API or a database.
- The tool list you advertise matches what the README claims the server does.
Once it holds, you can register the server's metadata so other people can find it — see how to publish an MCP server to the official MCP Registry.
Next step: Browse MCP servers on LLM Mart to compare focused tool designs before building your own, then check your own tool list against the narrowest one you find.
Sources
Comments (0)
Sign in to join the conversation.
No comments yet.