Build MCP servers
How to build an MCP server in TypeScript
A notes server in TypeScript with two tools that read and one that writes, served over stdio and over HTTP on Node.js. You will test it with the SDK's client and connect it to Claude Code.
The TypeScript SDK for MCP had a major release this year. Version 2 split the single @modelcontextprotocol/sdk package into separate server, client and framework packages, and most examples you will find online still use the old one. This tutorial uses version 2 throughout.
You will build a notes server with three tools. list_notes and read_note only read, and add_note writes a file. Keeping reads and writes in separate tools is what lets you give an agent one without the other later. By the end you will have the server running over stdio for local clients and over Streamable HTTP, a script that tests both, and an entry in Claude Code.
Every command and file below was run on the versions in the header.
Prerequisites
- Node.js 20 or newer. I used 26.9.
- npm, which comes with Node.js.
- Claude Code, if you want to try the server there in step 6.
Step 1: Create the project
Make a folder, create a package.json, switch it to ES modules, and install the packages:
mkdir notes-server && cd notes-server
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/client @modelcontextprotocol/node zod
npm install -D typescript tsx @types/nodeWhat each one is for:
@modelcontextprotocol/serverbuilds the server.@modelcontextprotocol/clientis only for the test script in step 3. A real server does not need it.@modelcontextprotocol/nodeadapts the HTTP handler to Node's built-inhttpmodule in step 4.zoddescribes each tool's input.tsxruns TypeScript directly while you work.
Then add a tsconfig.json:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"types": ["node"],
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}Two of those lines exist because of recent TypeScript changes. TypeScript 6 and later no longer pick up @types/node on their own, and the SDK's type definitions reference Node types, so "types": ["node"] is required. TypeScript 7 also stops with an error when outDir is set and rootDir is not. Leave either line out and the compile in step 5 fails.
Step 2: Write the server
Put the server in its own module so the stdio and HTTP entry points can share it. Create src/notes.ts:
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const NOTES_DIR = resolve(process.env.NOTES_DIR ?? 'notes');
// Map a title to a file inside NOTES_DIR, and refuse anything that would escape it.
function notePath(title: string): string {
const slug = title.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
if (!slug) throw new Error('A note needs a title with at least one letter or number.');
return join(NOTES_DIR, `${slug}.md`);
}
export function createNotesServer(): McpServer {
const server = new McpServer({ name: 'notes', version: '1.0.0' });
server.registerTool(
'list_notes',
{
description: 'List the titles of every saved note.',
inputSchema: z.object({}),
annotations: { readOnlyHint: true },
},
async () => {
await mkdir(NOTES_DIR, { recursive: true });
const titles = (await readdir(NOTES_DIR)).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3)).sort();
return { content: [{ type: 'text', text: JSON.stringify(titles) }], structuredContent: { titles } };
},
);
server.registerTool(
'read_note',
{
description: 'Return the text of one note.',
inputSchema: z.object({ title: z.string() }),
annotations: { readOnlyHint: true },
},
async ({ title }) => {
const path = notePath(title);
if (!existsSync(path)) throw new Error(`No note called "${title}".`);
return { content: [{ type: 'text', text: await readFile(path, 'utf8') }] };
},
);
server.registerTool(
'add_note',
{
description: 'Save a note. Fails if a note with that title already exists.',
inputSchema: z.object({ title: z.string(), body: z.string() }),
annotations: { readOnlyHint: false, destructiveHint: false },
},
async ({ title, body }) => {
const path = notePath(title);
if (existsSync(path)) throw new Error(`A note called "${title}" already exists.`);
await mkdir(NOTES_DIR, { recursive: true });
await writeFile(path, body);
return { content: [{ type: 'text', text: `Saved ${title}.` }] };
},
);
return server;
}registerTool takes a name, a config and a handler. The inputSchema is the only schema you write: the SDK turns it into the JSON Schema clients see, validates every call against it, and types the handler's arguments from it. The description is what the model reads when it decides which tool to call.
notePath turns every title into a slug of lowercase letters, numbers and dashes before it touches the disk, so ../../etc/passwd becomes etc-passwd.md inside the notes folder. Tool arguments come from the model, and the model got them from whatever it was reading. Validate them the way you would validate a form on a website.
When a handler throws, the client gets a result with isError: true and your message as its text, so the model can see what went wrong and try something else. The annotations are hints to the client. readOnlyHint: true says the tool changes nothing, but nothing enforces it.
Step 3: Serve it over stdio and test it
Create src/stdio.ts:
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createNotesServer } from './notes.js';
serveStdio(() => createNotesServer());
// stdout carries the protocol, so anything for humans goes to stderr.
console.error('notes server is listening on stdio');serveStdio takes a factory and owns the transport. In version 1 you created a StdioServerTransport and called server.connect yourself. On stdio, standard output is the protocol channel, so a console.log anywhere in the server breaks it. Use console.error for messages meant for people.
Now a client to exercise it. Create src/client.ts:
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const client = new Client({ name: 'notes-check', version: '1.0.0' });
await client.connect(new StdioClientTransport({ command: 'npx', args: ['tsx', 'src/stdio.ts'] }));
const { tools } = await client.listTools();
console.log(tools.map((t) => t.name));
await client.callTool({ name: 'add_note', arguments: { title: 'Release checklist', body: 'Tag, build, announce.' } });
console.log((await client.callTool({ name: 'list_notes', arguments: {} })).structuredContent);
console.log((await client.callTool({ name: 'read_note', arguments: { title: 'release checklist' } })).content);
const dup = await client.callTool({ name: 'add_note', arguments: { title: 'Release checklist', body: 'again' } });
console.log(dup.isError, dup.content);
await client.close();Run it:
npx tsx src/client.tsnotes server is listening on stdio
[ 'list_notes', 'read_note', 'add_note' ]
{ titles: [ 'release-checklist' ] }
[ { type: 'text', text: 'Tag, build, announce.' } ]
true [
{
type: 'text',
text: 'A note called "Release checklist" already exists.'
}
]The client started the server as a subprocess, listed the tools, saved and read a note, and got a readable error back for the duplicate. The banner line comes through because the client passes the server's standard error to your terminal.
Step 4: Serve it over Streamable HTTP
For clients on other machines, serve the same server over HTTP. Create src/http.ts:
import { createServer } from 'node:http';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node';
import { createNotesServer } from './notes.js';
const handle = toNodeHandler(createMcpHandler(() => createNotesServer()));
const validHost = localhostHostValidation();
const validOrigin = localhostOriginValidation();
createServer((req, res) => {
// Refuse requests whose Host or Origin is not localhost, which blocks DNS rebinding.
if (!validHost(req, res) || !validOrigin(req, res)) return;
void handle(req, res);
}).listen(3000, '127.0.0.1', () => console.error('notes server on http://127.0.0.1:3000/mcp'));Start it:
npx tsx src/http.tsThe two validation checks are there because a server on localhost is still reachable from a web page you visit. A malicious page can point its own domain at 127.0.0.1 and send requests from your browser, a trick called DNS rebinding. The checks reject any request whose Host or Origin header is not localhost. You can see it work:
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.example" -X POST http://127.0.0.1:3000/mcpThat prints 403. To test the real thing, create src/http-client.ts:
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const client = new Client({ name: 'notes-check', version: '1.0.0' });
await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/mcp')));
console.log((await client.listTools()).tools.map((t) => t.name));
console.log((await client.callTool({ name: 'list_notes', arguments: {} })).structuredContent);
await client.close();npx tsx src/http-client.ts[ 'list_notes', 'read_note', 'add_note' ]
{ titles: [ 'release-checklist' ] }When you deploy this somewhere public, the localhost checks have to allow your real hostname instead, and you need authentication in front of it. With no auth, an MCP server on the internet runs its tools for anyone who finds the URL.
Step 5: Build it
tsx is convenient while you work, but clients should run compiled JavaScript:
npx tscThat writes dist/notes.js, dist/stdio.js, dist/http.js and the two clients. To check the build, change the transport in src/client.ts to { command: 'node', args: ['dist/stdio.js'] } and run npx tsx src/client.ts again. You should get the same output.
Step 6: Add it to Claude Code
From the project folder:
claude mcp add --scope project notes -- node "$PWD/dist/stdio.js"That writes the server into a .mcp.json file in the folder. claude mcp get notes shows it as pending approval: project servers have to be approved before Claude Code runs them, since .mcp.json is meant to be committed and anyone cloning the repository would otherwise run its commands. Start claude in the folder, approve the server, and ask it to list your notes.
For the HTTP version, with src/http.ts running:
claude mcp add --scope project --transport http notes http://127.0.0.1:3000/mcpClaude Code loads servers when a session starts, so begin a new session after adding one.
Step 7: Put it behind Statio
Every client connected to this server can call all three tools. Once the server is shared, an agent that should only read notes can also write them, and the server has no way to tell agents apart.
Statio is a gateway that grants access one tool at a time. You can put a server you built behind it in two ways:
- Host it yourself and connect it by URL. Deploy the HTTP version where Statio can reach it and add it as an external server. This is on every plan, including Free.
- Give Statio a Docker image. From the Team plan up, Statio runs your image as it is.
A new server starts with nothing granted, not even to the person who added it, and grants are per tool. Here you would grant list_notes and read_note to the agents that only look things up, and add_note to the one that should write. The gateway checks the exact tool on each call, so a reading agent that tries add_note is refused, and the refusal shows up in the audit log next to the calls that went through. Clients connect to the gateway instead of the server; the setup is in Connect a client, and grants are explained in Permissions.
Conclusion
You built an MCP server with version 2 of the TypeScript SDK, with zod schemas for each tool, input that cannot escape its folder, and errors the model can read. You served it over stdio and over Streamable HTTP with DNS rebinding protection, tested both with the SDK's client, compiled it, and connected it to Claude Code.
Where to go next:
- How to build an MCP server in Python, the same server with the Python SDK.
- How to turn an OpenAPI spec into an MCP server, for an API that already has a spec.
- How to add MCP servers to Claude Code and how to set up MCP servers in Cursor, for the client side.
- How to give an AI agent read-only GitHub access, the same read and write split on a server you did not write.