# How to Build an MCP Server (TypeScript, End to End)

> A working Model Context Protocol server in TypeScript — project setup, tool registration with Zod, stdio transport, and wiring it into Claude Code and Cursor without breaking the JSON-RPC stream.

By Tahir Nazir · Published 2026-08-09 · Updated 2026-08-09
Canonical: https://www.its-tahir.com/blog/how-to-build-an-mcp-server

To build an MCP server you need four things: a Node project with `@modelcontextprotocol/server` and `zod` installed, an `McpServer` instance, one or more tools registered with `registerTool`, and a transport connected at the end of `main()`. That is the whole shape. Everything below is detail on getting each piece right — and on two mistakes the protocol docs single out for their own warnings: logging to stdout, and exposing too many tools.

**Key takeaways**

- An MCP server is a normal Node process. The protocol is JSON-RPC over stdio or streamable HTTP — there is no AI-specific magic in it.
- On stdio servers, console.log() corrupts the protocol stream. Use console.error() and never anything else.
- Tool descriptions are prompt engineering, not documentation. They are what the model reads when deciding whether to call you.
- Build once, register everywhere: Claude Code uses `claude mcp add`, Cursor uses .cursor/mcp.json, and the server binary is identical.
- Keep the tool surface small. Every schema you expose is context spent on every single request.

## What an MCP server actually is

The Model Context Protocol is an open standard for connecting AI applications to external systems — the official docs describe it as "a USB-C port for AI applications." A server exposes three kinds of capability: **tools** (functions the model can call), **resources** (file-like data the client can read), and **prompts** (reusable templates). Most servers people actually ship are tools-only, and that is what I'll build here.

The part that surprises people coming from plugin-style integrations: there is nothing model-specific in your code. You are writing a JSON-RPC service. The client — Claude Code, Cursor, VS Code, ChatGPT — handles the model side. Your job is to describe capabilities precisely and return text.

I've spent a lot of time on the client side of this problem. [ContextZero](/work/contextzero), the VS Code extension I maintain, exists because every agent stores its rules, memory and skills somewhere different — Claude Code, Cursor, Kiro, Antigravity, the AGENTS.md standard. MCP is the opposite lesson: one protocol, and the fragmentation disappears. Worth internalising before you write a line of code.

## Set up the project

```bash
mkdir weather-mcp && cd weather-mcp
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescript
mkdir src && touch src/index.ts
```

Two things in `package.json` matter. `"type": "module"` — the SDK is ESM. And a `bin` entry, because stdio servers are launched as executables by the client:

```json
{
  "type": "module",
  "bin": { "weather": "./build/index.js" },
  "scripts": { "build": "tsc && chmod 755 build/index.js" },
  "files": ["build"]
}
```

Target Node 20 or higher, and compile to `ES2022` with `Node16` module resolution. If you skip the build step and point a client at your TypeScript source, it will fail to start with an error that has nothing to do with MCP.

## Register a tool

`registerTool` takes three arguments: the tool name, a config object with a description and a Zod `inputSchema`, and an async handler that returns a content array.

```typescript
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";

const NWS_API_BASE = "https://api.weather.gov";

const server = new McpServer({
  name: "weather",
  version: "1.0.0",
});

server.registerTool(
  "get_alerts",
  {
    description: "Get active weather alerts for a US state",
    inputSchema: z.object({
      state: z
        .string()
        .length(2)
        .describe("Two-letter state code (e.g. CA, NY)"),
    }),
  },
  async ({ state }) => {
    const code = state.toUpperCase();
    // /alerts returns the full archive for the area; /alerts/active is the
    // subset that is in force right now — which is what the tool promises.
    const res = await fetch(`${NWS_API_BASE}/alerts/active?area=${code}`, {
      headers: { "User-Agent": "weather-app/1.0", Accept: "application/geo+json" },
    });

    if (!res.ok) {
      return {
        content: [{ type: "text", text: `Alert lookup failed: HTTP ${res.status}` }],
      };
    }

    const data = await res.json();
    const features = data.features ?? [];

    if (features.length === 0) {
      return { content: [{ type: "text", text: `No active alerts for ${code}` }] };
    }

    return {
      content: [
        {
          type: "text",
          text: features
            .map((f) => `${f.properties.event}: ${f.properties.headline}`)
            .join("\n"),
        },
      ],
    };
  },
);
```

### Write descriptions for the model, not for humans

This is the part engineers under-invest in. The `description` string and every `.describe()` on a schema field are the only things the model sees when deciding whether your tool is relevant. `"Get active weather alerts for a US state"` gets called correctly. `"Weather utility"` does not.

Be concrete about **when** to call the tool, not just what it does. If a tool only works for US locations, say so in the description — otherwise the model will call it for Lahore and you will burn a round trip on an error.

The corollary is that the description has to keep matching the implementation. `/alerts` and `/alerts/active` differ by one path segment and, for Wyoming on the day I checked, by 119 results versus 7 — the first is every alert the NWS has on file for the area, the second is the ones in force. A tool that promises "active" and calls `/alerts` isn't a documentation nit; the model has no way to know it was lied to, so it reports expired warnings as current with full confidence. Descriptions drift silently, because nothing type-checks them.

### Return errors as content, not exceptions

Notice that the HTTP failure above returns a normal content block rather than throwing. A thrown error is an opaque protocol failure; a text block saying "HTTP 503" is something the model can reason about and retry or route around. Same instinct as returning a useful HTTP error body instead of a bare 500.

## Connect the transport

```typescript
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Weather MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});
```

That `console.error` is not a typo, and it is the single most important line in this post.

### Never write to stdout

On a stdio server, stdout **is** the JSON-RPC channel. `console.log()` writes to stdout by default, so a single stray log line injects non-JSON into the message stream and breaks the connection. The MCP docs are blunt about it: "Writing to stdout will corrupt the JSON-RPC messages and break your server."

Use `console.error()`, which writes to stderr, or a logging library configured to write to stderr or a file. This bites people hardest when a transitive dependency logs a deprecation warning to stdout at import time — the server dies before it serves a single request, with no obvious cause. If a server mysteriously refuses to connect, grep your dependency tree for `console.log` before you touch anything else.

HTTP-based servers don't have this problem; stdout logging there is fine.

## Register it with your client

Build first — `npm run build` — then register the compiled entry point.

**Claude Code** uses the CLI. The `--` separator matters: everything after it is passed to your server untouched, so Claude Code doesn't try to parse your server's flags as its own.

```bash
# stdio, local
claude mcp add --transport stdio weather -- node /abs/path/to/weather-mcp/build/index.js

# with an env var, and shared with the team via .mcp.json
claude mcp add --env NWS_TOKEN=abc --transport stdio --scope project weather \
  -- node /abs/path/to/weather-mcp/build/index.js

# remote HTTP server
claude mcp add --transport http notion https://mcp.notion.com/mcp
```

Then `claude mcp list` shows a health status per server — `✔ Connected`, `! Needs authentication`, or `✘ Failed to connect`. If your server doesn't show up there, or shows a failure, [MCP server not showing up in Claude Code](/blog/mcp-server-not-showing-up-in-claude-code) explains what each status means and how to fix it.

**Cursor** uses a JSON file: `.cursor/mcp.json` in the project for project-scoped tools, or `~/.cursor/mcp.json` for tools available everywhere.

### Scope and transport, side by side

| | Claude Code | Cursor |
| --- | --- | --- |
| This project, shared with the team | `.mcp.json` in project root (`--scope project`) — commit it | `.cursor/mcp.json` — commit it |
| This project only, private to you | `~/.claude.json` (`--scope local`, the default) | not documented |
| All your projects, private to you | `~/.claude.json` (`--scope user`) | `~/.cursor/mcp.json` |
| Transports | stdio, streamable HTTP, SSE (deprecated), WebSocket | stdio, streamable HTTP, SSE |
| Registration | `claude mcp add` CLI | edit the JSON file |
| Health check | `claude mcp list` — except WebSocket servers, which don't appear there; use `claude mcp get <name>` or `/mcp` | MCP Logs in the Output panel |

The `--scope local` label trips people up, because "local" reads like "on my machine, everywhere." It is the opposite: local scope is the **default**, it stores the entry in `~/.claude.json` keyed by the current project's path, and the server appears in that one project only. `--scope user` is the "all my projects" option. Same file, different reach.

One gotcha worth knowing if you hand-write JSON for Claude Code: an entry with a `url` but no `type` is a configuration error, because Claude Code reads a typeless entry as a stdio server and skips it. The `type` field accepts `streamable-http` as an alias for `http`, so configs copied straight from a server's docs work unmodified.

## Keep the tool surface small

The instinct after your first working server is to expose everything. Resist it.

Every tool's name, description and input schema are loaded into the model's context on every request in that session. Twenty tools is a standing tax on every single turn, and it makes tool selection harder — the model has more near-synonyms to choose between. Claude Code also warns when MCP tool output exceeds 10,000 tokens and caps output at 25,000 by default, so a chatty tool that dumps a full API response is actively working against you.

Three rules I apply:

- **One tool per user intent, not per API endpoint.** `find_overdue_invoices` beats `list_invoices` plus `filter_invoices` plus `get_invoice`.
- **Return the answer, not the payload.** Format results as terse text. The model does not need your API's envelope, pagination cursors, or null-heavy fields.
- **Delete tools nobody calls.** If you can't remember the last time a tool fired, it is costing context for nothing.

This is the same discipline I write about in [Claude Code vs Cursor: how each one decides what your agent knows](/blog/claude-code-vs-cursor-context-management) — context is a budget, and MCP tool schemas spend from it whether you use them or not. If your tool fronts a search index, what it retrieves spends from the same budget, and [RAG retrieval that returns the wrong chunks](/blog/rag-retrieval-returns-the-wrong-chunks) is the guide for when those results are off.

## A checklist before you ship

- `npm run build` runs clean and `build/index.js` is executable.
- No `console.log` anywhere in the server or its runtime dependencies.
- Every tool has a description that says when to call it, and every schema field has `.describe()`.
- Failure paths return text content, not thrown errors.
- Secrets come from environment variables passed at registration, never hardcoded.
- You've reviewed what the server can reach. An MCP server that fetches external content is a prompt-injection surface — Claude Code's docs carry an explicit warning about this, and it applies to servers you write as much as ones you install.

If Claude Code itself is what's failing once the server is registered, [every Claude Code error and its fix](/blog/claude-code-troubleshooting) maps each error message to the post that solves it.

## FAQ

### Do I need a different MCP server for Claude Code and Cursor?

No. MCP is a protocol, not a vendor integration. The same stdio server binary registers with Claude Code via `claude mcp add --transport stdio`, and with Cursor via a `.cursor/mcp.json` entry. Only the registration file differs.

### Why does my MCP server connect and then immediately fail?

On a stdio server, the most common cause is writing to stdout. console.log() writes to stdout, which is the JSON-RPC channel, so any log line corrupts the message stream. Use console.error() or a logger that writes to stderr or a file.

### Should I use stdio or HTTP transport?

Use stdio when the server runs locally and needs machine access — filesystem, local databases, CLIs. Use streamable HTTP when the server is remote and shared across a team, because HTTP supports OAuth. Claude Code's docs deprecate SSE and tell you to use HTTP servers instead; Cursor's docs list stdio, SSE and streamable HTTP as peers and deprecate none of them, so the recommendation to prefer HTTP is Claude Code's, not a joint one.

### How many tools should one MCP server expose?

Fewer than you think. Every tool description is loaded into the model's context on every request, and Claude Code warns when MCP tool output exceeds 10,000 tokens. Three sharply-described tools beat fifteen vague ones.

## Sources

- [What is the Model Context Protocol (MCP)?](https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro)
- [Build an MCP server — Model Context Protocol](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server)
- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp)
- [Model Context Protocol — Cursor Docs](https://cursor.com/docs/mcp)
