DEV Community

Cover image for Build an MCP server in TypeScript: step by step with SDK v2
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Build an MCP server in TypeScript: step by step with SDK v2

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.log breaks 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/await and 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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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' };
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 in content, because not every client evaluates structured data.
  • annotations such as readOnlyHint are 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();
});
Enter fullscreen mode Exit fullscreen mode

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' > docs/adr/0001.md
npm run build
npx @modelcontextprotocol/inspector --cli node dist/src/index.js --method tools/list
npx @modelcontextprotocol/inspector --cli node dist/src/index.js --method tools/call --tool-name list_adrs
Enter fullscreen mode Exit fullscreen mode

If everything is right, you see the startup message on stderr followed by the response as JSON:

{
  "content": [
    {
      "type": "text",
      "text": "0001 – Events via EventBridge (Accepted)"
    }
  ],
  "structuredContent": {
    "adrs": [
      {
        "id": "0001",
        "title": "Events via EventBridge",
        "status": "Accepted"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Try the error case as well. With --tool-arg id=../x, the Zod schema already rejects the input; with id=0042, the server answers “ADR 0042 does not exist.” and isError: true. For the graphical interface, run npm run inspect and open the address it prints in your browser.

Step 7: Automated tests with node:test

The Inspector is great for trying things out, but for changes you need tests. I test the server the same way Claude Code uses it: the test starts the built server as a child process and talks to it over stdio. As a side effect, this checks that nothing lands on stdout that does not belong there.

// test/server.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const serverEntry = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'index.js');

async function connect(adrDir: string): Promise<Client> {
  const transport = new StdioClientTransport({
    command: process.execPath,
    args: [serverEntry],
    env: { ...process.env, ADR_DIR: adrDir } as Record<string, string>,
  });
  const client = new Client({ name: 'test-client', version: '1.0.0' });
  await client.connect(transport);
  return client;
}

test('lists tools and ADRs over stdio', async () => {
  const dir = await mkdtemp(path.join(tmpdir(), 'adr-'));
  await writeFile(path.join(dir, '0001.md'), '# Events via EventBridge\n\nStatus: Accepted\n');
  const client = await connect(dir);
  try {
    const { tools } = await client.listTools();
    assert.deepEqual(tools.map((t) => t.name).sort(), ['create_adr_draft', 'get_adr', 'list_adrs']);

    const result = await client.callTool({ name: 'list_adrs', arguments: {} });
    assert.deepEqual(result.structuredContent, {
      adrs: [{ id: '0001', title: 'Events via EventBridge', status: 'Accepted' }],
    });
  } finally {
    await client.close();
  }
});

test('creates a draft and reads it as a resource', async () => {
  const dir = await mkdtemp(path.join(tmpdir(), 'adr-'));
  const client = await connect(dir);
  try {
    const created = await client.callTool({
      name: 'create_adr_draft',
      arguments: {
        title: 'DynamoDB instead of RDS',
        context: 'We need a data store without connection pooling.',
        decision: 'We use DynamoDB in on-demand mode.',
      },
    });
    assert.equal((created.structuredContent as { id: string }).id, '0001');

    const res = await client.readResource({ uri: 'adr://0001' });
    const first = res.contents[0] as { text: string };
    assert.match(first.text, /Status: Draft/);
  } finally {
    await client.close();
  }
});

test('rejects path tricks and invalid input', async () => {
  const dir = await mkdtemp(path.join(tmpdir(), 'adr-'));
  const client = await connect(dir);
  try {
    const bad = await client.callTool({ name: 'get_adr', arguments: { id: '../../etc/passwd' } });
    assert.equal(bad.isError, true);

    const missing = await client.callTool({ name: 'get_adr', arguments: { id: '0042' } });
    assert.equal(missing.isError, true);
  } finally {
    await client.close();
  }
});
Enter fullscreen mode Exit fullscreen mode
npm test
Enter fullscreen mode Exit fullscreen mode

Expected result: three green tests, and for each test you see the startup message from stderr in the log. On my machine, the complete run takes just over a second.

Adding the MCP server to Claude Code

Step 8: Register the server

Claude Code starts stdio servers itself. You only tell it which command to use. Everything after the double dash is passed to your server unchanged. I use absolute paths so it does not matter which folder Claude Code is started from.

# run in your project root – the "local" scope applies to this project only
claude mcp add --env ADR_DIR="$PWD/docs/adr" --transport stdio adr \
  -- node "$HOME/tools/adr-mcp-server/dist/src/index.js"
claude mcp list
Enter fullscreen mode Exit fullscreen mode

In Claude Code, check with /mcp whether the server is connected and which tools it offers. Then ask, for example: “Which architecture decisions exist about events?” The agent should call list_adrs and then get_adr for the relevant entry.

To share the server with the whole team, add it with --scope project. Claude Code then writes it to the .mcp.json file in the repository. There you can use environment variables so nobody has to commit a personal path. Each developer sets ${ADR_MCP_HOME} themselves; for ADR_DIR, the default docs/adr applies if you start Claude Code in the project root:

{
  "mcpServers": {
    "adr": {
      "type": "stdio",
      "command": "node",
      "args": ["${ADR_MCP_HOME}/dist/src/index.js"],
      "env": { "ADR_DIR": "${ADR_DIR:-docs/adr}" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

For servers from a .mcp.json file, Claude Code asks on first start whether you trust them. That is a good thing.

Step 9: Protect write access

You can allow reading tools without asking, but I want to be asked before anything is written. In Claude Code, MCP tools follow the naming pattern mcp__<server>__<tool>. So the project configuration looks like this:

{
  "permissions": {
    "allow": ["mcp__adr__list_adrs", "mcp__adr__get_adr"],
    "ask": ["mcp__adr__create_adr_draft"]
  }
}
Enter fullscreen mode Exit fullscreen mode

If you need more control, use a hook with the matcher mcp__adr__.*, as described in part 1 of the series. That way you can, for example, log every call or allow drafts only on certain branches.

Common mistakes with an MCP server in TypeScript

Most pitfalls have little to do with MCP itself. They are small things that only show up when the server meets the host.

The server starts, but the host reports a connection error

Almost always something is writing to stdout. It can be your own console.log, but also a library that prints a warning on import. Start the server with the Inspector and check whether stray lines appear before the JSON response.

Imports or types cannot be found

Check whether the example you are copying still uses the old @modelcontextprotocol/sdk package. If "types": ["node"] is missing in tsconfig.json, the compiler reports unknown types like Buffer. And with "module": "NodeNext", relative imports need the .js extension, even though the source file is called .ts.

The agent ignores your tool

Then the description is usually too short or too technical. The model only sees name, description and schema. Describe the use case in one sentence and give the parameters an example with .describe(). Also: a few clearly scoped tools beat twenty that overlap. I show in detail how to phrase tool descriptions so that the agent uses them reliably in How to write tool descriptions for AI agents.

Relative paths point nowhere

The host starts your server with its own working directory. A relative path like docs/adr then only works by accident. Set the folder through an environment variable with an absolute path instead, as in step 8.

Security: what your server may do

An MCP server runs with your permissions. Anything your code can do may, in the worst case, be triggered by a model that has read a manipulated file. That is why I stick to a few simple rules:

  • Give each tool exactly one job and as few permissions as possible. Our server knows only one folder and cannot delete anything.
  • Validate input twice: once with Zod and once in the logic, as with the path check.
  • Treat annotations as a hint for the UI, not as protection.
  • Read secrets such as tokens from environment variables, never from tool arguments, and never return them in responses.
  • Make destructive actions traceable: drafts instead of direct changes, approval by a human.

When you connect third-party servers, even stricter rules apply. I wrote down what matters to me in Connecting MCP servers securely.

My take

For me, MCP is the most useful interface the AI world has produced in recent years. The technology is deliberately plain; the real gain lies elsewhere: one server works for many hosts. Once built, your team uses it in Claude Code, in the IDE or in its own agent.

At the same time, I see the risk of teams now building a server for everything. Every tool costs context, because its description ends up in the prompt. And every writing function is a new attack surface. My advice: start with reading tools, measure whether they really improve the agent’s answers, and add write access only once a human stays in the loop. For team knowledge like ADRs, this is a good starting point, because the benefit shows immediately and the risk stays small.

FAQ

What is the difference between MCP tools and MCP resources?

Tools are functions the model calls itself, and they can change things. Resources are readable content with a URI that the host or the user pulls into the context. Use tools for actions and resources for reference material.

Do I need stdio or HTTP for an MCP server?

For tools that run locally on a developer’s machine, stdio is the simplest solution. HTTP pays off when several users share a centrally operated server, but then you also need authentication.

Does my MCP server also work outside Claude Code?

Yes. MCP is an open protocol, and any host that supports MCP can start your server. Besides Claude Code, that includes Claude Desktop and various IDEs.

Can I keep using version 1 of the SDK?

Existing servers keep running, but for new projects I would start with version 2 right away. The project provides a dedicated migration guide for the switch.

Your next step

Pick one piece of information you keep explaining to your agent by hand and build a single reading tool for it. With the code from this article, that takes less than an hour. Then watch for a week how often the agent actually uses it. If your whole team should use the same tools later, my guide Run an MCP Server on Lambda shows you how to move the server to the cloud with proper sign-in.

I am curious: which team knowledge would you make available through MCP first? Let me know in the comments.

Next in the series

That was part 2 of the series AI Toolbox for Developers. Previously: Claude Code Hooks: Guardrails for Your AI Agent. The next part covers CLAUDE.md, skills and subagents, in other words how to store project knowledge so the agent finds it without a server of its own. I already show how to write a skill in Writing agent skills.

Sources

This post was created with AI assistance.


👉 Next step: New here? My start page collects the best articles, tools and learning paths in one place.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)