Building an MCP server in TypeScript was the moment AI agents turned from a pleasant chat partner into a real team member for me. As long as Claude Code only sees the code in the repository, it guesses on many questions: Why did we decide against RDS back then? Is there already an agreement on this? With your own server, you give the agent exactly the tools and data it needs, and you stay in control.
In the first part of the series, we looked at hooks, which are guardrails that slow an agent down. Today we turn things around and give it new abilities. By the end, you will have a working server that makes your project’s architecture decisions readable, with automated tests, a check in the Inspector and a clean setup in Claude Code.
In short
An MCP server is a small program that offers tools and data to an AI agent through a fixed protocol. With version 2 of the official TypeScript SDK, you need about 100 lines of code, Zod schemas for the inputs and an entry point over stdio. The most common pitfall is mundane: a single
console.logbreaks the connection, because stdout belongs to the protocol.
What you will learn in this article
- What an MCP server is and when building your own pays off
- How to register tools and resources with SDK v2 and Zod
- How to check the server with the Inspector and node:test
- How to add it to Claude Code and protect write access
What is an MCP server?
MCP stands for Model Context Protocol. It is an open protocol that AI applications use to talk to external tools and data sources. There are three roles. The host is the application you work in, for example Claude Code, Claude Desktop or an IDE. Inside the host sits a client that holds the connection. On the other side is the server, which is the program we write today.
A server can offer three kinds of things. Tools are functions the model may call, such as “search ticket” or “create draft”. Resources are readable content with a URI, similar to files. Prompts are prepared templates a user picks deliberately. For getting started, tools and resources are all you need.
The messages themselves are JSON-RPC, small JSON objects with a method, parameters and an ID. They travel either over stdio (the host starts your server as a child process and writes to its standard input) or over HTTP for servers that run centrally on the network. For a tool that only works on your machine and in your repository, stdio is the simplest choice. That is what we build.
🎬 Flow of a tool call between Claude Code and the MCP server – this animated diagram only runs on my site: watch it on sascha-ms.de
When is your own MCP server worth it?
By now there are ready-made servers for GitHub, databases, browsers and much more. You write your own when the knowledge or workflows only exist in your team. Typical candidates are internal conventions, architecture decisions, a catalog of your services or a tool you already drive with scripts.
My rule of thumb: if you paste the same information into the chat for the third time, it is a candidate. The reverse holds too: if a single line in CLAUDE.md solves the task, you do not need a server. MCP pays off as soon as data changes, needs structured queries or an action has to follow clear rules.
Our example: a server for architecture decisions
We build a server for ADRs. ADR stands for Architecture Decision Record, a short Markdown file per decision with context, decision and consequences. Many teams keep them in docs/adr. The server offers three tools: list_adrs lists all decisions, get_adr returns one in full and create_adr_draft creates a new draft. In addition, every ADR is readable as the resource adr://0001.
I picked this example on purpose. It is small enough for an article, yet it covers everything that matters in practice: reading and writing tools, input validation, structured responses and file access that needs protection.
Prerequisites
- Node.js 20 or newer (I tested with Node.js 22)
- npm and a terminal
- Basic TypeScript knowledge, such as
async/awaitand types - For step 8: Claude Code or another MCP-capable host
You do not need an AWS account this time. The server runs entirely locally and costs nothing but a few seconds of compute.
MCP server in TypeScript, step by step
I use version 2 of the official SDK. Good to know: version 1 was a single package called @modelcontextprotocol/sdk. Version 2 is split into several packages, and for us @modelcontextprotocol/server and, for testing, @modelcontextprotocol/client matter. Version 2 implements the MCP specification from July 28, 2026. Many tutorials online still show the old imports. If an example does not compile for you, that is often the reason.
Step 1: Create the project
Create a folder and install the dependencies. I pin the versions so the example still works the same way in six months.
mkdir adr-mcp-server && cd adr-mcp-server npm init -y npm install @modelcontextprotocol/server@2.4.0 zod@4.6.7 npm install --save-dev @modelcontextprotocol/client@2.4.0 typescript@7.0.2 @types/node@22.20.5 mkdir -p src test docs/adr Then replace package.json with this version. What matters is "type": "module" and the three scripts.
{ "name": "adr-mcp-server", "version": "1.0.0", "private": true, "type": "module", "scripts": { "build": "tsc", "test": "npm run build && node --test \"dist/test/*.test.js\"", "inspect": "npx @modelcontextprotocol/inspector node dist/src/index.js" }, "dependencies": { "@modelcontextprotocol/server": "2.4.0", "zod": "4.6.7" }, "devDependencies": { "@modelcontextprotocol/client": "2.4.0", "@types/node": "22.20.5", "typescript": "7.0.2" }, "engines": { "node": ">=20" } } Step 2: Configure TypeScript
Since TypeScript 6, types from @types are no longer included automatically. The SDK, however, refers to Node types such as Buffer. That is why "types": ["node"] must be in the configuration, otherwise you get a flood of errors.
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "outDir": "dist", "rootDir": ".", "types": ["node"], "skipLibCheck": true }, "include": ["src", "test"] } Step 3: Data access with path protection
I keep the file logic separate from the protocol. This keeps the server code lean and lets you test it on its own. The most important part is in fileFor(): the agent may only pass IDs of exactly four digits, and the resulting path must be inside the ADR folder. That way nobody can escape the folder with ../, not even a model that takes a prompt from a foreign file too seriously.
// src/adr-store.ts import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; export type AdrSummary = { id: string; title: string; status: string }; // Reads title and status from an ADR file in Markdown format. function parseAdr(id: string, text: string): AdrSummary { const title = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? id; const status = /^Status:\s*(.+)$/im.exec(text)?.[1]?.trim() ?? 'unknown'; return { id, title, status }; } export class AdrStore { private readonly root: string; constructor(root: string) { this.root = path.resolve(root); } // Only allows IDs like "0007" – so nobody can escape the folder via "../". private fileFor(id: string): string { if (!/^\d{4}$/.test(id)) { throw new Error(`Invalid ADR ID "${id}" – expected four digits, e.g. 0007.`); } const file = path.resolve(this.root, `${id}.md`); if (path.dirname(file) !== this.root) { throw new Error('Path is outside the ADR folder.'); } return file; } async list(): Promise<AdrSummary[]> { await mkdir(this.root, { recursive: true }); const files = (await readdir(this.root)).filter((f) => /^\d{4}\.md$/.test(f)).sort(); return Promise.all( files.map(async (f) => { const id = f.slice(0, 4); return parseAdr(id, await readFile(path.join(this.root, f), 'utf8')); }), ); } async read(id: string): Promise<string> { try { return await readFile(this.fileFor(id), 'utf8'); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { throw new Error(`ADR ${id} does not exist.`); } throw err; } } // Creates a new draft with the next free number. Existing files stay untouched. async createDraft(title: string, context: string, decision: string): Promise<AdrSummary> { const existing = await this.list(); const next = existing.length === 0 ? 1 : Number(existing[existing.length - 1].id) + 1; const id = String(next).padStart(4, '0'); const today = new Date().toISOString().slice(0, 10); const text = [ `# ${title}`, '', 'Status: Draft', `Date: ${today}`, '', '## Context', '', context, '', '## Decision', '', decision, '', '## Consequences', '', '_Still open – please complete during review._', '', ].join('\n'); // flag "wx": fail if the file exists after all await writeFile(this.fileFor(id), text, { encoding: 'utf8', flag: 'wx' }); return { id, title, status: 'Draft' }; } } Note the wx flag when writing. It makes writeFile fail if the file already exists. So the server can create new drafts, but it can never overwrite an existing decision.
Step 4: Register tools and the resource
Now for the core. With registerTool you register a tool with a name, a configuration and a function that does the work. The configuration matters more than it looks, because the model decides only from name and description when to use your tool.
// src/server.ts import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; import { AdrStore } from './adr-store.js'; const AdrSummarySchema = z.object({ id: z.string(), title: z.string(), status: z.string() }); export function createServer(adrDir: string): McpServer { const store = new AdrStore(adrDir); const server = new McpServer({ name: 'adr', version: '1.0.0' }); // Tool 1: list all decisions (read-only) server.registerTool( 'list_adrs', { title: 'List ADRs', description: 'Lists all Architecture Decision Records (ADRs) of the project with ID, title and status. ' + 'Use this before you propose an architecture decision.', outputSchema: z.object({ adrs: z.array(AdrSummarySchema) }), annotations: { readOnlyHint: true }, }, async () => { const adrs = await store.list(); const text = adrs.length ? adrs.map((a) => `${a.id} – ${a.title} (${a.status})`).join('\n') : 'No ADRs yet.'; return { content: [{ type: 'text', text }], structuredContent: { adrs } }; }, ); // Tool 2: read one decision in full (read-only) server.registerTool( 'get_adr', { title: 'Read ADR', description: 'Returns the full Markdown text of an ADR.', inputSchema: z.object({ id: z.string().regex(/^\d{4}$/).describe('Four-digit ADR number, e.g. 0003'), }), annotations: { readOnlyHint: true }, }, async ({ id }) => ({ content: [{ type: 'text', text: await store.read(id) }] }), ); // Tool 3: create a new draft (writes, but never overwrites) server.registerTool( 'create_adr_draft', { title: 'Create ADR draft', description: 'Creates a new ADR with status "Draft". Never overwrites existing files. ' + 'A human reviews and adopts the draft.', inputSchema: z.object({ title: z.string().min(5).max(120).describe('Short title of the decision'), context: z.string().min(20).max(4000).describe('Why is this decision needed?'), decision: z.string().min(20).max(4000).describe('What is being decided?'), }), outputSchema: AdrSummarySchema, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, }, async ({ title, context, decision }) => { const adr = await store.createDraft(title, context, decision); return { content: [{ type: 'text', text: `Draft ${adr.id} created: ${adr.title}` }], structuredContent: adr, }; }, ); // Resource template: every ADR is also readable as resource adr://<id> server.registerResource( 'adr', new ResourceTemplate('adr://{id}', { list: async () => ({ resources: (await store.list()).map((a) => ({ uri: `adr://${a.id}`, name: `${a.id} ${a.title}`, mimeType: 'text/markdown', })), }), }), { title: 'Architecture Decision Record', mimeType: 'text/markdown' }, async (uri, { id }) => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: await store.read(String(id)) }], }), ); return server; } A few details I consider important:
- description is an instruction for the model. Write down when the tool is useful, not only what it does. The sentence “Use this before you propose an architecture decision” makes a noticeable difference in practice.
- inputSchema is a Zod schema. The SDK derives the JSON Schema for the agent from it, validates the arguments and infers the TypeScript types for your function. Invalid input never reaches your code.
- outputSchema describes the structured response in
structuredContent. The SDK validates it before it leaves the server. You should still send the text incontent, because not every client evaluates structured data. - annotations such as
readOnlyHintare hints for the host, for example for approval dialogs. They do not change execution. A real barrier is something you have to build yourself, as in step 3.
If your function throws an error, the SDK turns it into a regular tool response with isError: true. The model sees the error message and can react, for example with a corrected ID. Write error messages so they actually help.
Step 5: Entry point over stdio
The entry point is short. serveStdio handles the transport and calls your factory function for the connection. In SDK v1 you created and connected a StdioServerTransport yourself; that is gone.
// src/index.ts import { serveStdio } from '@modelcontextprotocol/server/stdio'; import { createServer } from './server.js'; // Folder via environment variable, default: docs/adr in the current project const adrDir = process.env.ADR_DIR ?? 'docs/adr'; const handle = serveStdio(() => createServer(adrDir)); // Important: stdout belongs to the protocol. Always log to stderr. console.error(`adr-mcp-server running (stdio), ADR folder: ${adrDir}`); process.on('SIGINT', () => { void handle.close(); }); The most important line is the comment. The host reads every line on stdout as a JSON-RPC message. A forgotten console.log("debug") slips a non-JSON line in between, and the connection breaks. Logs therefore always go to stderr, meaning console.error.
Step 6: Build and test with the Inspector
First create a sample decision and build the project. The MCP Inspector is the official testing tool. It has a web interface and a command-line mode that works well for quick checks.
printf '# Events via EventBridge\n\nStatus: Accepted\n' >Source:https://dev.to/sascha-ms/build-an-mcp-server-in-typescript-step-by-step-with-sdk-v2-3n7kRelated posts
Best shadcn registries for ready-made blocks in 2026
How we score an election forecast (and what the score does not measure)
Handling Failures as Values in Agent-Generated Code
My Favourite Laptop

Render Personalized Images from One Editable Template with ModelFold Studio
