AI agents and MCP · 8 min read
Build an MCP Server in TypeScript (Tested Step by Step)
A tested tutorial for building an MCP server in TypeScript with the official SDK: define a tool with Zod, serve it over stdio and Streamable HTTP, test it with the Inspector, register it in Claude Code, and see what to add before production.
By The Vexio team Published Updated
Build an MCP server in TypeScript: what you will end up with
By the end you will have a TypeScript MCP server with one tool, get-order-status. It runs over stdio, passes a scripted client test, shows up in the MCP Inspector and in Claude Code, and the same code can be served over Streamable HTTP. The tool reads an in-memory map, so there are no external dependencies; swap the map for a call to your own API and you have the shape of a real server. If the term is new, what an MCP server is explains the roles first.
Which package: @modelcontextprotocol/server or @modelcontextprotocol/sdk?
As documented on 2026-10-14, the main branch of the official repository is v2 of the SDK, the stable line, published as split packages: @modelcontextprotocol/server (2.3.1 on npm), @modelcontextprotocol/client (2.3.1) and an optional @modelcontextprotocol/node adapter (2.1.1). v2 implements the 2026-07-28 protocol revision.
The single package @modelcontextprotocol/sdk (1.32.1 on npm) is the v1.x maintenance line. The repository says it implements the spec up to 2025-11-25 and will keep receiving bug and security fixes for at least six months after the v2 release on 2026-07-27. This tutorial uses v2 and shows the v1 equivalent near the end. Everything below was run on Node.js 24.3.0 with TypeScript 7.0.2, zod 4.6.7 and tsx 4.23.15.
What do you need installed?
- Node.js 20 or later. The v2 packages declare
node >=20. - Node.js 22.19 or later if you want the MCP Inspector, whose npm package declares
node >=22.19.0. - Basic TypeScript. Tool inputs are described with Zod.
Step 1: Create the project
mkdir orders-mcp && cd orders-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
npm install -D typescript @types/node
mkdir src
type=module matters because the SDK ships ES modules only. tsx runs TypeScript directly, so there is no build step while you experiment. To type-check or build, this tsconfig.json passed in testing:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
With TypeScript 7.0.2 the Node types were not picked up without the types entry, and NodeNext is why the imports below end in .js.
Step 2: Define the server and one tool
Create src/server.ts:
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
// Demo data. In a real server this would call your own API or database.
const orders = new Map([
['A-1001', { status: 'shipped', total: 42.5 }],
['A-1002', { status: 'processing', total: 18.0 }]
]);
export function createServer(): McpServer {
const server = new McpServer({ name: 'orders', version: '1.0.0' });
server.registerTool(
'get-order-status',
{
description: 'Look up the status and total of one order by its id',
inputSchema: z.object({
orderId: z.string().describe('Order id, for example A-1001')
})
},
async ({ orderId }) => {
const order = orders.get(orderId);
if (!order) {
return {
content: [{ type: 'text', text: `No order ${orderId}. Known ids: ${[...orders.keys()].join(', ')}` }],
isError: true
};
}
return {
content: [{ type: 'text', text: `${orderId}: ${order.status}, total ${order.total}` }]
};
}
);
return server;
}
Four things are happening.
- A factory. The server is built inside
createServer. Both transports below take a factory, and the HTTP handler calls it once per request, so register everything inside it. registerTool(name, config, handler). The name anddescriptionare what the model reads when it decides whether to call the tool. Write them for a model.- One schema. The Zod
inputSchemais the only schema you write. The SDK turns it into the JSON Schema clients see intools/list, validates arguments before your handler runs, and infers the handler’s types. The.describe()text becomes the parameter’s description. - Errors the model can use. A missing order returns a normal result with
isError: trueand a message naming the valid ids, so the model can retry. The SDK also converts an exception thrown in a handler into the sameisError: trueshape.
Step 3: Serve it over stdio
Create src/stdio.ts:
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './server.js';
void serveStdio(createServer);
console.error('orders MCP server running on stdio');
serveStdio reads requests on standard input, writes responses to standard output and calls your factory to build the server. Run it with npx tsx src/stdio.ts. It prints the banner and waits, which is correct: a stdio server waits for a client to speak first.
The banner uses console.error on purpose. The specification says a stdio server must not write anything to standard output that is not a valid MCP message, so a single console.log corrupts the connection.
Step 4: How do you test an MCP server without an AI app?
The MCP Inspector launches your server command and lets you call tools directly. In the browser version, click Connect, open the Tools tab, pick get-order-status and run it with A-1001:
npx @modelcontextprotocol/inspector npx tsx src/stdio.ts
Its command-line mode needs no browser:
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts --method tools/list
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts --method tools/call --tool-name get-order-status --tool-arg orderId=A-1001
The first command prints the tool with the input schema generated from your Zod object. The second prints A-1001: shipped, total 42.5.
To test from code, install the client package (npm install @modelcontextprotocol/client) and create src/client-test.ts:
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(new StdioClientTransport({ command: 'npx', args: ['tsx', 'src/stdio.ts'] }));
console.log((await client.listTools()).tools.map(t => t.name));
console.log(await client.callTool({ name: 'get-order-status', arguments: { orderId: 'A-1002' } }));
console.log(await client.callTool({ name: 'get-order-status', arguments: { orderId: 'nope' } }));
await client.close();
Run it with npx tsx src/client-test.ts. The second call returns the text A-1002: processing, total 18. The third returns isError: true with the list of known ids.
Step 5: Register the server in a client
Every host launches a stdio server from a command and arguments. For Claude Code, the SDK documentation gives this command, run from the project root:
claude mcp add orders -- npx tsx src/stdio.ts
Run /mcp in a Claude Code session in that directory to confirm orders is connected. VS Code reads a .vscode/mcp.json file with a stdio entry holding the same command and arguments, and Cursor reads .cursor/mcp.json. Then ask “what is the status of order A-1001?” Nothing in the prompt names the tool. The model picks it from the name, description and schema, and the specification recommends that hosts let you deny the call.
For a built copy, run npx tsc and register node dist/stdio.js instead of the tsx command.
Step 6: Serve the same server over Streamable HTTP
Stdio suits a server a host launches on your machine. To run one endpoint that many clients connect to, serve the same factory over Streamable HTTP. Install the Node adapter with npm install @modelcontextprotocol/node, then create src/http.ts:
import { createServer as createHttpServer } from 'node:http';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node';
import { createServer } from './server.js';
const handler = createMcpHandler(createServer);
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', () => {
console.error('orders MCP server on http://127.0.0.1:3000/mcp');
});
Run npx tsx src/http.ts, then connect with the client package:
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/mcp')));
console.log(await client.callTool({ name: 'get-order-status', arguments: { orderId: 'A-1001' } }));
await client.close();
Three details matter.
- The factory runs per request.
createMcpHandlerbuilds a fresh server for every HTTP request and holds nothing between requests, so the endpoint is stateless and can scale horizontally. Create connection pools once at module scope and close over them. - The handler checks no Host, Origin or token. The SDK documentation says you add those checks in front of it. The two
localhost*Validationguards do that for a local bind, and the specification requires servers to validate theOriginheader to prevent DNS rebinding. In testing, a POST carrying an unrelatedOriginheader got a 403 before reaching the handler. - Bind to loopback while developing. The specification says a locally running server should bind to localhost, which is why the example listens on
127.0.0.1.
Express, Hono and Fastify have helper packages that apply the header checks by default on localhost binds.
What to add before this leaves your laptop
A tutorial server is not a production server. At minimum:
- Authenticate remote access. For HTTP, the specification describes OAuth-based authorization in which your server accepts only tokens issued for it. The SDK’s authorization guide covers a bearer-token gate, but it never issues tokens: you supply the identity provider and a function that verifies each token.
- Limit and sanitise. Schema validation is done for you. The specification also lists rate limits, access controls and output sanitising as server obligations, and those are yours.
- Choose the identity. Give the downstream API a scoped credential, not an admin one.
- Preview writes. For a tool that changes data, consider a dry-run default and an audit log, and keep read and write tools separate so a host can treat them differently.
What a hosted server should expose is a design question: MCP vs REST API covers where the line sits. Hosting it is ordinary cloud and DevOps work.
If your project is on the v1 SDK
A server with the same tool on @modelcontextprotocol/sdk 1.32.1 looks like this, with the handler body shortened:
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: 'orders', version: '1.0.0' });
server.registerTool(
'get-order-status',
{
description: 'Look up the status and total of one order by its id',
inputSchema: { orderId: z.string().describe('Order id, for example A-1001') }
},
async ({ orderId }) => ({ content: [{ type: 'text', text: `Looked up ${orderId}` }] })
);
await server.connect(new StdioServerTransport());
The differences are the import paths, server.connect(transport) in place of serveStdio, and inputSchema taking a plain object of Zod fields. The HTTP side differs more. The repository publishes a migration guide to v2.
Building this against your own systems
Vexio builds custom MCP servers for teams that want an assistant to work against their own systems. The pattern is the one above, applied to real data: tools designed around tasks, a scoped identity, a dry-run default on anything that writes, and an audit log. If you are weighing a build against hiring, how to choose an AI agent development company and what drives custom AI agent cost help frame it. See the MCP server development page or request a demo.
Sources
Checked on 2026-10-14. Code run locally on Node.js 24.3.0 with @modelcontextprotocol/server 2.3.1, @modelcontextprotocol/node 2.1.1, @modelcontextprotocol/client 2.3.1, zod 4.6.7, tsx 4.23.15 and typescript 7.0.2, plus @modelcontextprotocol/sdk 1.32.1 for the v1 snippet.
- MCP TypeScript SDK repository,
mainbranch README (v2 packages, install, quickstart): https://github.com/modelcontextprotocol/typescript-sdk - MCP TypeScript SDK repository,
v1.xbranch README (maintenance line, spec coverage): https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x - SDK docs, “Build your first server”: https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/docs/get-started/first-server.md
- SDK docs, “Plug into a real host”: https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/docs/get-started/real-host.md
- SDK docs, “Serve over HTTP”: https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/docs/serving/http.md
- SDK docs, “Authorization”: https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/docs/serving/authorization.md
- SDK docs, “Tools” and “Errors”: https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/docs/servers/tools.md and https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/docs/servers/errors.md
- npm registry metadata (versions, Node engines): https://registry.npmjs.org/@modelcontextprotocol/server, https://registry.npmjs.org/@modelcontextprotocol/inspector and https://registry.npmjs.org/@modelcontextprotocol/sdk
- MCP Inspector repository and CLI README: https://github.com/modelcontextprotocol/inspector and https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/clients/cli/README.md
- MCP specification (revision 2026-07-28), stdio transport: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio
- MCP specification, Streamable HTTP transport: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
- MCP specification, Tools: https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- MCP specification, Authorization: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization