Build MCP servers

How to build an MCP server in TypeScript

A notes server in TypeScript, run with Bun over stdio and HTTP, tested with the SDK's client and connected to Claude Code.

By
Dan Flanagan
Updated
October 14, 2026
Tested with
Bun 1.3.4, @modelcontextprotocol/server 2.3.1, @modelcontextprotocol/client 2.3.1, zod 4.6.7, Node.js 26.9 and Claude Code 2.1.299 on macOS 26.6
Reading time
4 min

New to MCP? What is an MCP server? covers the basics first.

Most TypeScript MCP examples online still import @modelcontextprotocol/sdk. Version 2 of the SDK split that into separate server and client packages, and this tutorial uses version 2 with Bun.

The server keeps notes. list_notes and read_note only read and add_note writes, so later you can give an agent the reads without the write. Every command below was run on the versions in the header.

Prerequisites

  • Bun 1.3 or newer. I used 1.3.4.
  • Claude Code, if you want to try the server there in step 6.

Step 1: Create the project

Make a folder and install the packages:

bash
mkdir notes-server && cd notes-server
bun init -y
bun add @modelcontextprotocol/server @modelcontextprotocol/client zod

What each one is for:

  • @modelcontextprotocol/server builds the server.
  • @modelcontextprotocol/client is only for the test script in step 3.
  • zod describes each tool's input.

The tsconfig.json from bun init works as it is, and Bun runs TypeScript directly, so there's no compile step while you work. You can delete the index.ts it creates.

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:

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;
}

The inputSchema is the only schema you write. The SDK turns it into the JSON Schema clients see and validates every call against it. The description is what the model reads when it picks a tool.

notePath turns every title into a slug before it touches the disk, so ../../etc/passwd becomes etc-passwd.md inside the notes folder. Tool arguments come from the model, which got them from whatever it was reading, so validate them like form input.

When a handler throws, the client gets isError: true with your message, so the model can see what went wrong and try something else. readOnlyHint: true is only a hint to the client. Nothing enforces it.

Step 3: Serve it over stdio and test it

Create src/stdio.ts:

ts
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createNotesServer } from './notes.ts';

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 handles the transport, which you wired up by hand in version 1. Standard output is the protocol channel on stdio, so a console.log anywhere in the server breaks it.

Then a client to test it. Create src/client.ts:

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: 'bun', args: ['run', '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:

bash
bun run src/client.ts
text
notes 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 duplicate came back as a readable error. The banner line shows up because the client passes the server's standard error through 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:

ts
import {
  createMcpHandler,
  hostHeaderValidationResponse,
  localhostAllowedHostnames,
  localhostAllowedOrigins,
  originValidationResponse,
} from '@modelcontextprotocol/server';
import { createNotesServer } from './notes.ts';

const handler = createMcpHandler(() => createNotesServer());

Bun.serve({
  hostname: '127.0.0.1',
  port: 3000,
  routes: {
    '/mcp': (request) =>
      // Refuse requests whose Host or Origin is not localhost, which blocks DNS rebinding.
      hostHeaderValidationResponse(request, localhostAllowedHostnames()) ??
      originValidationResponse(request, localhostAllowedOrigins()) ??
      handler.fetch(request),
  },
});

console.error('notes server on http://127.0.0.1:3000/mcp');

createMcpHandler returns a handler with a standard fetch method, and Bun.serve takes that as it is. Start it:

bash
bun run src/http.ts

The validation checks matter 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 (DNS rebinding). The checks reject any request whose Host or Origin isn't localhost:

bash
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.example" -X POST http://127.0.0.1:3000/mcp

That prints 403. To test the real thing, create src/http-client.ts:

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();
bash
bun run src/http-client.ts
text
[ "list_notes", "read_note", "add_note" ]
{
  titles: [ "release-checklist" ],
}

To deploy it publicly, change the checks to allow your real hostname and put authentication in front. Without auth it will run its tools for anyone who finds the URL.

Step 5: Build it

bun run is all you need while you work. To hand the server to a client as one file, compile it to a standalone executable:

bash
bun build src/stdio.ts --compile --outfile dist/notes-stdio

dist/notes-stdio includes the Bun runtime, so the machine running it doesn't need Bun. To check it, change the transport in src/client.ts to { command: 'dist/notes-stdio' } and run bun run src/client.ts again for the same output.

Step 6: Add it to Claude Code

From the project folder:

bash
claude mcp add --scope project notes -- "$PWD/dist/notes-stdio"

That writes a .mcp.json file in the folder, and claude mcp get notes shows it as pending approval. Claude Code asks before running project servers because .mcp.json gets committed, and cloning a repository shouldn't run its commands. Start claude in the folder, approve it, and ask it to list your notes.

For the HTTP version, with src/http.ts running:

bash
claude mcp add --scope project --transport http notes http://127.0.0.1:3000/mcp

Claude 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, and the server can't tell agents apart. Once it's shared, an agent that should only read notes can write them too.

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. A reading agent that tries add_note is refused, and the refusal shows up in the audit log. Clients connect to the gateway instead of the server, as described in Connect a client. Grants are explained in Permissions.

Running it on Node instead

The server code also works on Node.js 24 or newer, which runs .ts files directly. Swap bun add for npm install, and in src/client.ts start the server with { command: 'node', args: ['src/stdio.ts'] }. Bun.serve only exists in Bun, so on Node the HTTP entry point uses node:http with the adapter from @modelcontextprotocol/node.

bash
npm install @modelcontextprotocol/server @modelcontextprotocol/client @modelcontextprotocol/node zod
node src/client.ts
ts
import { createServer } from 'node:http';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node';
import { createNotesServer } from './notes.ts';

const handle = toNodeHandler(createMcpHandler(() => createNotesServer()));
const validHost = localhostHostValidation();
const validOrigin = localhostOriginValidation();

createServer((req, res) => {
  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'));

Conclusion

You now have a TypeScript MCP server on version 2 of the SDK, running under Bun over stdio or Streamable HTTP, compiled to one file and connected to Claude Code.

Where to go next: