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.
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, 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
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.tsTwo 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:
{
"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.
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
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.
# 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/mcpThen claude mcp list shows a health status per server — ✔ Connected, ! Needs authentication, or ✘ Failed to connect.
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_invoicesbeatslist_invoicesplusfilter_invoicesplusget_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 — context is a budget, and MCP tool schemas spend from it whether you use them or not.
A checklist before you ship
npm run buildruns clean andbuild/index.jsis executable.- No
console.loganywhere 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.
Frequently asked questions
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.
References
- What is the Model Context Protocol (MCP)?modelcontextprotocol.io · accessed 2026-08-09
- Build an MCP server — Model Context Protocolmodelcontextprotocol.io · accessed 2026-08-09
- MCP TypeScript SDKgithub.com · accessed 2026-08-09
- Connect Claude Code to tools via MCPcode.claude.com · accessed 2026-08-09
- Model Context Protocol — Cursor Docscursor.com · accessed 2026-08-09
Last reviewed August 9, 2026
Tahir Nazir
Senior AI Engineer & Full-Stack Lead
5+ years shipping AI-powered products — RAG pipelines, agentic workflows, and MCP tooling. Top Rated on Upwork with a 100% job success score.
More about Tahir →Keep reading
New posts land here first. Follow along by RSS, or get in touch if you are building something similar.
Related articles
Claude Code vs Cursor: How Each One Decides What Your Agent Knows
Both tools read project instructions from disk, but they disagree on file names, load order, and scoping. Here is the concrete difference — and how to keep one repo working well in both.ComparisonAI Engineering8 min readRAG Retrieval Returns the Wrong Chunks: A Production Debugging Guide
Your RAG demo worked and production doesn't. Before you swap the embedding model, check the four things that actually break retrieval: recall settings, chunk boundaries, keyword blindness, and where the answer sits in the prompt.TroubleshootingAI Engineering11 min read