# Build an MCP server in TypeScript (2026 spec, step by step)

> Build an MCP server in TypeScript with SDK v2 and the stateless 2026-07-28 spec: three tested tools, stdio and HTTP, a client, and a v1 migration cheat sheet.

Source: https://devaiper.com/blog/build-mcp-server-typescript
Published: 2026-10-11
Topics: MCP, TypeScript, Tutorial, Agents

**Short answer:** An MCP server in TypeScript is a factory that returns an McpServer with typed tools. The 2026-07-28 spec dropped the handshake and sessions, so build one fresh server per request, validate inputs with zod 4.2 or newer, test with the Inspector first, then serve it over stdio or HTTP.

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](https://devaiper.com/blog/what-is-mcp) and [MCP vs API](https://devaiper.com/blog/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.

> **Diagram:** Stateful MCP versus stateless MCP. Before, the client and server opened a session with an initialize handshake, every message carried a session id, and the server remembered each client. Now every request carries its version and capabilities, state is an explicit handle passed as an argument, and any server copy can answer any request.
> Before: stateful: initialize handshake first; Mcp-Session-Id on every message; Server remembers each client; Two copies need sticky sessions. Now: stateless (2026-07-28): Each request carries _meta; No session id; State is a handle in arguments; Any copy can answer any request.

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-Method` and `Mcp-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/client` and adapters such as `@modelcontextprotocol/node`.

## How do I set up the project?

```bash
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/node
```

Set `"type": "module"` in `package.json`. Then a `tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
```

Three things bite people here:

1. **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.
2. **`"types": ["node"]`.** TypeScript 6 and up no longer includes `@types/*` packages automatically, and the SDK's types reference `Buffer`.
3. **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

```ts
// 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:

```ts
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!,
  };
};
```

```ts
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 old `server.tool()` shortcut, which is gone in v2.
- **The input is a `z.object`.** `limit` has 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 with `isError: true`.
- **`outputSchema` and `structuredContent`.** You return human-readable `content` and typed `structuredContent`. 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: true`** tells 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`

```ts
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`

```ts
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](https://devaiper.com/blog/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:

```bash
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
```

```text
{
  "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:

```ts
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.

```bash
npx tsx --test test/server.test.ts
```

## How 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.

```ts
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:

1. **stdout is the protocol.** One stray `console.log` corrupts the stream and the server "connects, then drops". Log with `console.error`, which goes to stderr.
2. **Pass settings explicitly.** In my own test the Inspector's launcher did not forward my shell's `REPO_SCOUT_REPO` variable to the server. The server then ran in a folder that was not a repo, and git answered `fatal: not a git repository`. Clients commonly start stdio servers with a restricted environment, so put the variables in the client's `env` block (or `-e` in 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):

```json
{
  "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.

```ts
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.

```bash
REPO_SCOUT_REPO=/path/to/your/repo npm run http
```

```bash
#!/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"'}}'; echo
```

Real output (the `tools/list` response is cut for length):

```text
--- 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/discover` answers `Method not found`.
- **Version in the body but no header:** you get a clear error saying the headers and body disagree.

```text
{"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?

```ts
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();
```

```text
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](https://devaiper.com/blog/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

- [MCP specification 2026-07-28: key changes](https://modelcontextprotocol.io/specification/2026-07-28/changelog)
- [TypeScript SDK v2 documentation](https://ts.sdk.modelcontextprotocol.io/v2/)

*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.*

## FAQ

### 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.

