How to Make an MCP Server in TypeScript, Step by Step
A step-by-step build of an MCP server with the official TypeScript SDK: a tool with structured output, a resource and a prompt, compiled to JavaScript, tested in-process with node:test, checked in the MCP Inspector, and packaged so anyone can start it with npx.
7 min read
To make an MCP server in TypeScript, install @modelcontextprotocol/server and zod, write a factory function that creates an McpServer and registers what it offers (tools with registerTool, readable data with registerResource, reusable instructions with registerPrompt), and pass that factory to serveStdio. Compile it with tsc, test it in-process with the SDK’s client, check it in the MCP Inspector, then connect it to a client or publish it to npm so it starts with npx. As of September 30, 2026, version 2 of the SDK is the stable line and implements the 2026-07-28 protocol revision; the steps below use it.
This guide goes one layer past the quick start. For the minimal version and the rules for designing tools a model picks well, read how to build an MCP server first. The same build in Python is MCP server in Python, and in C# is MCP server in C#.
What you will make
A small server for a team’s runbooks, the short how-to pages people follow during an incident. It shows all three things a server can offer, explained in tools, resources and prompts:
- A tool,
runbooks_find, that the model calls to search titles and that returns structured output a program can check. - A resource template,
runbooks://{slug}, that a client can list and read without the model deciding to call anything. - A prompt,
incident_summary, that a person picks from a menu to start a summary from one runbook.
1. Set up a project that compiles
The SDK’s packages page (opens in a new tab) says a server needs one package, @modelcontextprotocol/server, and the SDK ships ES modules only, so the project is "type": "module". Its tutorial asks for Node.js 20 or later. The client package is only for the tests, so it goes in dev dependencies.
mkdir runbooks-mcp && cd runbooks-mcp npm init -y npm pkg set type=module npm install @modelcontextprotocol/server zod npm install -D typescript tsx @types/node @modelcontextprotocol/client mkdir src test
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "src",
"outDir": "build",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}Compiling is optional while you develop, because tsx runs TypeScript directly. It matters when you ship: a host then starts node build/index.js, which is faster and has no dev tools to install. With Node16 module resolution, relative imports need the .js extension, even in .ts files.
2. Write the server as a factory
Keep the server in its own file and export a function that builds it. The stdio entry, the tests and a later HTTP entry all call the same factory. The Map stands in for your wiki or docs API.
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// Stands in for your team's wiki or docs API.
const runbooks = new Map([
['deploy-rollback', { title: 'Roll back a bad deploy', steps: 'Find the last good release, redeploy it, post in #incidents.' }],
['db-failover', { title: 'Fail over the primary database', steps: 'Page the on-call DBA, promote the replica, update the connection string.' }],
]);
export function createServer(): McpServer {
const server = new McpServer({ name: 'runbooks', version: '1.0.0' });
server.registerTool(
'runbooks_find',
{
description: 'Find runbooks by a word in their title. Read one with the runbooks://{slug} resource.',
inputSchema: z.object({
query: z.string().min(2).describe('A word from the title, e.g. "rollback"'),
}),
outputSchema: z.object({
matches: z.array(z.object({ slug: z.string(), title: z.string() })),
}),
annotations: { readOnlyHint: true },
},
async ({ query }) => {
const q = query.toLowerCase();
const matches = [...runbooks]
.filter(([, r]) => r.title.toLowerCase().includes(q))
.map(([slug, r]) => ({ slug, title: r.title }));
return {
content: [{ type: 'text', text: JSON.stringify({ matches }) }],
structuredContent: { matches },
};
},
);
server.registerResource(
'runbook',
new ResourceTemplate('runbooks://{slug}', {
list: async () => ({
resources: [...runbooks].map(([slug, r]) => ({ uri: 'runbooks://' + slug, name: r.title })),
}),
}),
{ description: 'One runbook, as plain text', mimeType: 'text/plain' },
async (uri, { slug }) => {
const runbook = runbooks.get(String(slug));
if (!runbook) throw new Error('No runbook called ' + String(slug));
return { contents: [{ uri: uri.href, text: runbook.title + '\n\n' + runbook.steps }] };
},
);
server.registerPrompt(
'incident_summary',
{
description: 'Start an incident summary from the runbook that was followed',
argsSchema: z.object({ slug: z.string().describe('The runbook that was followed') }),
},
({ slug }) => ({
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: 'Read runbooks://' + slug + ' and draft a five-line incident summary: what broke, when, what we did, what is left, who owns it.',
},
},
],
}),
);
return server;
}- One Zod schema per input does three jobs: the SDK turns it into the JSON Schema the model sees, validates arguments before your handler runs, and types the handler. A one-letter query never reaches your code; it comes back as a result with
isError: truethat the model can read and correct. outputSchemaplusstructuredContentgives programs a typed result next to the text the model reads. The SDK’s tools guide (opens in a new tab) says the SDK validatesstructuredContentagainst the schema before the result leaves your server.readOnlyHinttells a client the tool changes nothing, which some hosts use to skip a confirmation. It is a hint, never a security control.- Resources are data the client reads; the
listfunction lets it show every runbook without guessing slugs. Prompts are templates a person chooses, often shown as slash commands.
3. Add the stdio entry point
#!/usr/bin/env node
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './server.js';
void serveStdio(createServer);
console.error('runbooks MCP server running on stdio');The first line lets npm run the compiled file as a command. Over stdio, standard output is the protocol channel, so log with console.error; one console.log corrupts the stream. Run npx tsc and you have build/index.js.
4. Write a test that runs without a host
The SDK’s testing guide (opens in a new tab) drives the server through a real client in the same process: createMcpHandler serves the factory, and the client transport sends its requests to handler.fetch instead of the network. No port, no child process, and it exercises the same code you deploy. This version uses Node’s built-in test runner.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { createServer } from '../src/server.js';
test('runbooks_find returns structured matches and rejects short queries', async () => {
const handler = createMcpHandler(createServer);
const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
fetch: (url, init) => handler.fetch(new Request(url, init)),
});
const client = new Client({ name: 'test', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);
const found = await client.callTool({ name: 'runbooks_find', arguments: { query: 'roll' } });
assert.deepEqual(found.structuredContent, {
matches: [{ slug: 'deploy-rollback', title: 'Roll back a bad deploy' }],
});
const rejected = await client.callTool({ name: 'runbooks_find', arguments: { query: 'x' } });
assert.equal(rejected.isError, true);
await client.close();
await handler.close();
});node --import tsx --test test/server.test.ts
Assert on results, not on thrown errors: a tool failure arrives as an ordinary result with isError: true. Test the refusals as carefully as the happy path, because they are what the model reads when something goes wrong. To cover the stdio entry itself, the same guide spawns the built file with StdioClientTransport.
5. Look at it in the MCP Inspector
npx @modelcontextprotocol/inspector node build/index.js
The Inspector starts your server and opens a browser page. Check three tabs: Tools (call runbooks_find and read both the text and the structured result), Resources (the two runbooks should be listed and readable), and Prompts (fill in a slug and look at the message it builds). The Inspector needs a newer Node.js than the SDK does; the MCP Inspector guide covers that, its CLI mode and debugging.
6. Connect it to a client
# Claude Code: everything after -- is the launch command claude mcp add runbooks -- node /absolute/path/to/runbooks-mcp/build/index.js
Run /mcp in Claude Code to see it connected, then ask “which runbook covers a bad deploy?” VS Code, Cursor and Claude Desktop take the same command and arguments in their own files; VS Code mcp.json and Claude Desktop config show where. Open WebUI is the exception people ask about: its MCP support (opens in a new tab) is Streamable HTTP only, so serve the same factory over HTTP, as the HTTP section of how to build an MCP server shows, and add its URL under the admin settings’ external tool servers.
7. Package it so anyone can run it with npx
{
"name": "@your-org/runbooks-mcp",
"version": "1.0.0",
"type": "module",
"bin": { "runbooks-mcp": "build/index.js" },
"files": ["build"],
"scripts": {
"build": "tsc",
"test": "node --import tsx --test test/server.test.ts",
"prepublishOnly": "npm run build && npm test"
},
"mcpName": "io.github.your-name/runbooks-mcp"
}After npm publish, the launch command becomes npx -y @your-org/runbooks-mcp, with nothing to clone. The mcpName field is what the official MCP Registry checks when you list an npm package there; the MCP Registry walks through publishing with mcp-publisher, and where to find MCP servers covers the catalogs people browse. If your server holds anything private, do not publish it publicly: keep it on a private feed and an internal list.
Best practices worth keeping from day one
- Prefix tool names with your product (
runbooks_find, notfind), because clients connect many servers at once. - Put the next step in every refusal: “No runbook called db-restore. Call runbooks_find first.”
- Make reads read-only and say so, and keep any write behind its own tool that a host can ask before running.
- Return small results. Everything you send back is context the model has to read, as MCP token usage explains.
- Add sign-in before the server leaves your machine: how to add authentication to an MCP server.
Keeping the server’s own backlog
A new server collects work fast: a description the model misreads, a resource that should have been a tool, a refusal that needs a better sentence. fenbs is a task board that is itself an MCP server at https://fenbs.ai/api/mcp, so the assistant testing your server can file what it finds as a bug with fenbs_create_item, with the failing test or Inspector call in the note and how to fix it in the plan. Tasks move through To Do, Next Up, In Progress and Completed, each finished one records how it was tested, and History shows which changes the assistant made.
Related
Tool design and remote HTTP: how to build an MCP server. When things fail: MCP error handling and how to debug MCP tools. stdio or HTTP: MCP transports. Connecting fenbs: Claude Code integration.