Build an MCP server in TypeScript (2026 spec, step by step)
Your AI assistant can write a function in four seconds. Ask it "what are the open TODOs in my repo?" and it guesses, or asks you to paste files. It has no hands. An MCP server gives it hands.
In this tutorial we build a real one in TypeScript: three tools that read a git repo, tested, and running over both stdio and HTTP. Everything here was run in October 2026, and it uses the new MCP spec and SDK, because MCP changed a lot in July and many tutorials still teach the old way.
What are we building?#
A small server called repo-scout with three read-only tools:
| Tool | What it does |
|---|---|
git_log | Lists recent commits, as typed JSON |
search_code | Searches tracked files for plain text |
list_todos | Lists every TODO and FIXME comment |
If you want the idea of MCP first, read what is MCP and MCP vs API. Come back when you want to build.
What changed in MCP in 2026?#
The spec revision dated 2026-07-28 made MCP stateless. Before, a client opened a connection with an initialize handshake and every message carried a session id, so the server had to remember each client. Run two copies behind a load balancer and you needed sticky sessions or shared storage.
Now there is no handshake and no session header. Every request carries what the server needs, in a _meta field: the protocol version and the client's capabilities. If a server really has to remember something across calls, it hands out an explicit handle, like a ticket number, and the model passes it back as a normal tool argument.
A few other changes you will meet in this tutorial:
- A new method,
server/discover, where a server says which protocol versions it supports and who it is. - HTTP requests carry routing headers,
Mcp-MethodandMcp-Name, so gateways can route without reading the body. - Roots, sampling and logging are deprecated: they still work, but do not build new things on them. The old HTTP+SSE transport is deprecated too.
- The TypeScript SDK is now v2, split into packages:
@modelcontextprotocol/server,@modelcontextprotocol/clientand adapters such as@modelcontextprotocol/node.
How do I set up the project?#
mkdir repo-scout-mcp && cd repo-scout-mcp
npm init -y
npm install @modelcontextprotocol/server @modelcontextprotocol/node @modelcontextprotocol/client zod
npm install -D typescript tsx @types/nodeSet "type": "module" in package.json. Then a tsconfig.json:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "test"]
}Three things bite people here:
- zod 4.2 or newer. Zod 3 is not supported in v2. It can install and type-check, then fail at the first
tools/list. On zod 4.0 or 4.1 the SDK loses your.describe()text. "types": ["node"]. TypeScript 6 and up no longer includes@types/*packages automatically, and the SDK's types referenceBuffer.- The new package names.
import { McpServer } from "@modelcontextprotocol/server", not the old single package.
For testing you also need a git repo with a few commits and a TODO. Any repo works, and the tests below create a throwaway one for you.
How do I write the server?#
Create src/server.ts. We build it in four small pieces.
1. A safe way to run git#
// One tiny helper: run git inside the repo we were pointed at.
async function git(repo: string, ...args: string[]) {
const { stdout } = await run("git", ["-C", repo, ...args], {
timeout: 15_000,
maxBuffer: 10_000_000,
});
return stdout.trim();
}
// git grep exits with code 1 when nothing matches. That is an empty
// result, not a failure: every other error must reach the caller.
const noMatch = (err: unknown) =>
(err as { code?: number }).code === 1;
const text = (t: string) => ({
content: [{ type: "text" as const, text: t }],
});I use execFile, not exec. There is no shell, so nothing the model sends can turn into a shell command. Dash -C points git at the repo, the timeout stops a hung process from freezing the server, and noMatch handles one git quirk: git grep exits with code 1 when nothing matches. That is an empty result, not a failure, and every other error must reach the caller. (I got this wrong in my first draft by catching everything and answering "No matches", which hid a real "not a git repository" error. More on that in the test section.)
2. The first tool: git_log#
Wrap the server in a factory so a fresh one can be built whenever needed:
const Commit = z.object({
sha: z.string(),
date: z.string(),
author: z.string(),
subject: z.string(),
});
type Commit = z.infer<typeof Commit>;
const parseCommit = (line: string): Commit => {
const [sha, date, author, subject] = line.split("\t");
return {
sha: sha!,
date: date!,
author: author!,
subject: subject!,
};
};server.registerTool(
"git_log",
{
title: "Recent commits",
description: "List the most recent commits.",
inputSchema: z.object({
limit: z.number().int().min(1).max(50).default(10),
}),
outputSchema: z.object({ commits: z.array(Commit) }),
annotations: { readOnlyHint: true },
},
async ({ limit }) => {
const out = await git(
repo,
"log",
`-n${limit}`,
"--date=short",
"--pretty=format:%h\t%ad\t%an\t%s",
);
const commits = out
.split("\n")
.filter(Boolean)
.map(parseCommit);
return {
...text(
commits.map((c) => `${c.sha} ${c.subject}`).join("\n"),
),
structuredContent: { commits },
};
},
);What is happening here:
registerTool(name, config, handler)replaces the oldserver.tool()shortcut, which is gone in v2.- The input is a
z.object.limithas a min, a max and a default, so the model cannot ask for 10,000 commits: the schema rejects the call before your code runs, and the client gets a result withisError: true. outputSchemaandstructuredContent. You return human-readablecontentand typedstructuredContent. The SDK validates your output against the schema before it leaves the server, so a parsing bug becomes an error here instead of garbage downstream.readOnlyHint: truetells clients the tool changes nothing, so they can skip scary confirmation prompts. It is a hint only: it does not change how the tool runs.
3. The second tool: search_code#
const FLAGS = ["-n", "-I", "-F"]; // line numbers, skip binaries, fixed string
server.registerTool(
"search_code",
{
title: "Search code",
description:
"Search tracked files for plain text. Returns file:line:text.",
inputSchema: z.object({
pattern: z
.string()
.min(1)
.describe("Text to look for (not a regex)"),
limit: z.number().int().min(1).max(100).default(20),
}),
annotations: { readOnlyHint: true },
},
async ({ pattern, limit }) => {
try {
// -e: the next argument is a pattern, even if it starts with "-"
const out = await git(repo, "grep", ...FLAGS, "-e", pattern);
return text(out.split("\n").slice(0, limit).join("\n"));
} catch (err) {
if (noMatch(err)) return text("No matches.");
throw err;
}
},
);Look at the flags. -F means fixed string, so the pattern is plain text and not a regex. -e means the next argument is the pattern even if it starts with a dash. Without -e, a model that searches for --help would hand git an option. Two tiny flags, real security.
4. The third tool: list_todos#
server.registerTool(
"list_todos",
{
title: "List TODOs",
description: "List every TODO and FIXME comment in the repo.",
annotations: { readOnlyHint: true },
},
async () => {
try {
return text(
await git(repo, "grep", "-nI", "-E", "TODO|FIXME"),
);
} catch (err) {
if (noMatch(err))
return text("No TODO or FIXME comments found.");
throw err;
}
},
);No input, so no inputSchema. It still has a good description, because the model picks tools by reading descriptions. Write them like a one-line job ad. For more on keeping tools safe, see MCP security risks.
How do I test an MCP server before connecting an AI?#
Rule: never connect a new server to an AI before you have called it by hand. Two ways.
The Inspector, a tool that lists your tools and calls them. First create src/stdio.ts (the next section explains it), then use the command-line mode:
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio.ts \
-e REPO_SCOUT_REPO=/path/to/your/repo \
--method tools/call --tool-name search_code --tool-arg pattern=TODO{
"content": [
{
"type": "text",
"text": "discount.js:1:// TODO: support percentage discounts\nshipping.js:1:// TODO: free shipping above 50"
}
]
}(Run it without --cli for the browser UI. The -e matters, and the reason is a real gotcha I hit; see the stdio section.)
Unit tests, which connect a client and a server in memory with no network and no process:
import {
Client,
InMemoryTransport,
} from "@modelcontextprotocol/client";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, test } from "node:test";
import { createServer } from "../src/server.js";
let client: Client;
// A throwaway git repo with two commits and one TODO to search for.
function makeRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "repo-scout-"));
const git = (...a: string[]) =>
execFileSync("git", ["-C", dir, ...a]);
git("init", "-q", "-b", "main");
git("config", "user.name", "Dev Aiper");
git("config", "user.email", "dev@example.com");
writeFileSync(
join(dir, "cart.js"),
"export const total = () => 0;\n",
);
git("add", "-A");
git("commit", "-q", "-m", "Initial commit: cart total");
writeFileSync(
join(dir, "ship.js"),
"// TODO: free shipping above 50\n",
);
git("add", "-A");
git("commit", "-q", "-m", "Add shipping");
return dir;
}
before(async () => {
const [clientSide, serverSide] =
InMemoryTransport.createLinkedPair();
await createServer(makeRepo()).connect(serverSide);
client = new Client({ name: "test", version: "1.0.0" });
await client.connect(clientSide);
});
after(async () => {
await client.close();
});
test("lists the three tools", async () => {
const { tools } = await client.listTools();
const names = tools.map((t) => t.name).sort();
assert.deepEqual(names, ["git_log", "list_todos", "search_code"]);
});
test("git_log returns structured commits", async () => {
const res = await client.callTool({
name: "git_log",
arguments: { limit: 2 },
});
const { commits } = res.structuredContent as {
commits: { subject: string }[];
};
assert.equal(commits.length, 2);
assert.equal(commits[0]!.subject, "Add shipping");
});
test("search_code treats the pattern as plain text", async () => {
// "--help" must be searched for, never handed to git as an option.
const res = await client.callTool({
name: "search_code",
arguments: { pattern: "--help" },
});
assert.match(JSON.stringify(res.content), /No matches/);
});
test("list_todos finds the TODO", async () => {
const res = await client.callTool({
name: "list_todos",
arguments: {},
});
assert.match(JSON.stringify(res.content), /free shipping above 50/);
});
test("bad arguments are rejected before the handler runs", async () => {
const res = await client.callTool({
name: "git_log",
arguments: { limit: 9999 },
});
assert.equal(res.isError, true);
});
test("a real git error is an error, not an empty result", async () => {
// A folder that is not a git repo: git fails with a code other than 1.
const [c, s] = InMemoryTransport.createLinkedPair();
await createServer(
mkdtempSync(join(tmpdir(), "not-a-repo-")),
).connect(s);
const lone = new Client({ name: "test", version: "1.0.0" });
await lone.connect(c);
const res = await lone.callTool({
name: "search_code",
arguments: { pattern: "TODO" },
});
assert.equal(res.isError, true);
await lone.close();
});Six tests: the tool list, structured commits, a pattern treated as plain text, the TODO finder, a limit of 9,999 rejected before the handler runs, and a real git error reported as an error. That last test exists because of the bug I mentioned: my first version answered "No matches" for everything, including a folder that was not a git repo. A server that hides errors as empty results makes the model confidently wrong.
npx tsx --test test/server.test.tsHow do I run it over stdio?#
stdio is the local way: the AI app starts your server as a subprocess and talks through standard input and output.
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./server.js";
// stdout carries the protocol: anything you print goes to stderr.
const handle = serveStdio(() => createServer());
console.error("repo-scout is listening on stdio");
process.on("SIGINT", () => {
void handle.close();
});serveStdio takes the same factory. Two rules:
- stdout is the protocol. One stray
console.logcorrupts the stream and the server "connects, then drops". Log withconsole.error, which goes to stderr. - Pass settings explicitly. In my own test the Inspector's launcher did not forward my shell's
REPO_SCOUT_REPOvariable to the server. The server then ran in a folder that was not a repo, and git answeredfatal: not a git repository. Clients commonly start stdio servers with a restricted environment, so put the variables in the client'senvblock (or-ein the Inspector) and use absolute paths.
To add the server to an AI app, most clients take a block like this (check your client's docs for the exact file and key names):
{
"mcpServers": {
"repo-scout": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/src/stdio.ts"],
"env": { "REPO_SCOUT_REPO": "/absolute/path/to/your/repo" }
}
}
}How do I run it over HTTP?#
HTTP is for sharing a server or running it remotely.
import { createMcpHandler } from "@modelcontextprotocol/server";
import {
localhostHostValidation,
localhostOriginValidation,
toNodeHandler,
} from "@modelcontextprotocol/node";
import { createServer as createHttpServer } from "node:http";
import { createServer } from "./server.js";
const PORT = Number(process.env.PORT ?? 3333);
// One server per request, nothing kept between requests:
// N copies can sit behind a plain round-robin load balancer.
const handler = createMcpHandler(() => createServer());
const nodeHandler = toNodeHandler(handler);
// The handler does not check Host or Origin, so we do it in front.
// This blocks DNS-rebinding from web pages to your local server.
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createHttpServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(PORT, "127.0.0.1", () => {
console.error(`repo-scout on http://127.0.0.1:${PORT}/mcp`);
});createMcpHandler takes the factory and builds a fresh server per request, keeping nothing between requests. That is the stateless model in one line, and it is why any copy can sit behind a plain round-robin load balancer.
Notice the two validation lines. The handler does not check the Host or Origin headers, so we do it in front. That blocks a malicious web page from reaching a server on your laptop through DNS rebinding. We also bind to 127.0.0.1, not 0.0.0.0: a local server should stay local.
What does a stateless MCP call look like on the wire?#
Start the server, then call it with plain curl. No SDK, no handshake, no session.
REPO_SCOUT_REPO=/path/to/your/repo npm run http#!/usr/bin/env bash
# Talk to the server with plain curl: no SDK, no handshake, no session id.
# Start it first: REPO_SCOUT_REPO=/path/to/a/git/repo npm run http
URL=${1:-http://127.0.0.1:3333/mcp}
H=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "MCP-Protocol-Version: 2026-07-28")
META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'
echo "--- 1. server/discover"
curl -s "$URL" "${H[@]}" -H "Mcp-Method: server/discover" \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{'"$META"'}}'; echo
echo "--- 2. tools/list (cold call: no initialize, no session)"
curl -s "$URL" "${H[@]}" -H "Mcp-Method: tools/list" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{'"$META"'}}' | head -c 400; echo " ..."
echo "--- 3. tools/call list_todos"
curl -s "$URL" "${H[@]}" -H "Mcp-Method: tools/call" -H "Mcp-Name: list_todos" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_todos","arguments":{},'"$META"'}}'; echoReal output (the tools/list response is cut for length):
--- 1. server/discover
{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true}},"resultType":"complete","ttlMs":0,"cacheScope":"private","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"repo-scout","version":"1.0.0"}}},"jsonrpc":"2.0","id":1}
--- 2. tools/list (cold call: no initialize, no session)
{"result":{"tools":[{"name":"git_log","title":"Recent commits","description":"List the most recent commits.","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"limit":{"default":10,"type":"integer","minimum":1,"maximum":50}}},"annotations":{"readOnlyHint":true},"outputSchema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"objec ...
--- 3. tools/call list_todos
{"result":{"content":[{"type":"text","text":"discount.js:1:// TODO: support percentage discounts\nshipping.js:1:// TODO: free shipping above 50\nshipping.js:2:// FIXME: handle international addresses"}],"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"repo-scout","version":"1.0.0"}}},"jsonrpc":"2.0","id":3}The first call is server/discover: the server answers with the versions it supports and its identity. The second is a cold tools/list, with no initialize before it, and it just works. The third is tools/call, with the tool name in the Mcp-Name header.
Why does server/discover say "method not found"?#
I hit this myself. The server wants an MCP-Protocol-Version: 2026-07-28 header on every request.
- No header, and no version in the body: the server treats you as an old client, and
server/discoveranswersMethod not found. - Version in the body but no header: you get a clear error saying the headers and body disagree.
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}Send the header and it works.
How do I write an MCP client?#
import {
Client,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
const url = process.argv[2] ?? "http://127.0.0.1:3333/mcp";
const client = new Client({
name: "repo-scout-demo-client",
version: "1.0.0",
});
await client.connect(new StreamableHTTPClientTransport(new URL(url)));
console.log("server:", client.getServerVersion());
console.log("capabilities:", client.getServerCapabilities());
const { tools } = await client.listTools();
console.log("tools:", tools.map((t) => t.name).join(", "));
const result = await client.callTool({
name: "git_log",
arguments: { limit: 3 },
});
console.log(
"git_log ->",
JSON.stringify(result.structuredContent, null, 2),
);
await client.close();server: { name: 'repo-scout', version: '1.0.0' }
capabilities: { tools: { listChanged: true } }
tools: git_log, search_code, list_todos
git_log -> {
"commits": [
{
"sha": "07aa2bf",
"date": "2026-10-03",
"author": "Dev Aiper",
"subject": "Add shipping calculation"
},
{
"sha": "0870206",
"date": "2026-09-28",
"author": "Dev Aiper",
"subject": "Add discount codes"
},
{
"sha": "ca4a3bf",
"date": "2026-09-20",
"author": "Dev Aiper",
"subject": "Initial commit: cart total"
}
]
}This is what an AI app does under the hood: connect, list the tools, put the descriptions in front of the model, and call a tool when the model asks. The next article uses this exact server from an agent: MCP vs A2A.
How do I migrate from SDK v1 to v2?#
| v1 | v2 | |
|---|---|---|
| Packages | @modelcontextprotocol/sdk | /server, /client and adapters (/node, /express, /hono) |
| Register a tool | server.tool(name, desc, shape, handler) | server.registerTool(name, config, handler) |
| Input schema | raw zod shape, zod 3 | z.object(...), zod 4.2 or newer |
| Start stdio | new StdioServerTransport() plus connect | serveStdio(() => server) |
| Sessions | session id, sticky sessions | stateless: a factory per request, handles for state |
The old SSE transport is removed from the v2 SDK. If you still use it, move to Streamable HTTP, which is what we used above.
Troubleshooting: five quick fixes#
| Symptom | Likely cause | Fix |
|---|---|---|
| Connects, then drops | console.log on a stdio server | Use console.error |
tools/list fails at runtime | zod 3 installed | zod 4.2 or newer |
Buffer type errors | types: ["node"] missing | Add it to tsconfig.json |
server/discover: method not found | No version header | Send MCP-Protocol-Version: 2026-07-28 |
| Works in the Inspector, not in my app | Settings not forwarded, relative paths | Use the client's env block and absolute paths |
Official docs#
Tested in October 2026 with Node 24, TypeScript 7, @modelcontextprotocol/server 2.3.1, @modelcontextprotocol/client 2.3.1, @modelcontextprotocol/node 2.1.1 and zod 4.6. The spec and SDK move quickly, so check the versions before you copy.
Frequently asked questions
What do I need to build an MCP server in TypeScript?
Node.js, TypeScript, the @modelcontextprotocol/server package, the @modelcontextprotocol/node adapter for HTTP, and zod 4.2 or newer for schemas. This tutorial was tested with Node 24, TypeScript 7, SDK 2.3.1 and zod 4.6.
Is MCP stateless now?
Yes. The 2026-07-28 spec removed the initialize handshake and the Mcp-Session-Id header. Every request carries its protocol version and client capabilities in _meta, and state that must survive between calls is passed as an explicit handle in tool arguments.
Should I run my MCP server over stdio or HTTP?
Use stdio when the AI app should start the server as a local subprocess. Use Streamable HTTP when you want to share the server, run it remotely or put several copies behind a load balancer.
Why does my MCP server work in the Inspector but not in my AI app?
The usual causes are printing to stdout on a stdio server, which corrupts the protocol stream, settings passed through environment variables that the client does not forward, and relative paths in the client config. Log to stderr, pass environment variables explicitly and use absolute paths.
How do I migrate an MCP server from SDK v1 to v2?
Replace the single @modelcontextprotocol/sdk package with the server, client and adapter packages, change server.tool() to registerTool() with a z.object input schema on zod 4.2 or newer, start stdio with serveStdio(), and stop relying on sessions.
Prefer plain text? Read this page as Markdown.