# How to build an MCP server in TypeScript

Canonical: https://scalably.io/blog/mcp-server-typescript
Author: Pavle Lazic, Founder & CEO, ScalablyAI (https://scalably.io/author/pavle-lazic)
Published: 2026-06-20 · Last updated: 2026-06-20 · SDK facts current as of @modelcontextprotocol/sdk circa mid-2026
This is the machine-readable representation of the article at the canonical URL. Same facts, denser format. The full article carries the complete runnable code.

## Direct answer

A working MCP server in TypeScript is one `McpServer` instance from `@modelcontextprotocol/sdk`, at least one tool registered with `registerTool` (name + config with description/inputSchema + async handler), and a `StdioServerTransport` connected with `await server.connect(transport)`. Inputs are described as a plain object of zod types (NOT wrapped in `z.object()`); the SDK converts them to JSON Schema for the client and validates calls before your handler runs. Choose TypeScript when the thing the server wraps already lives in the JS/Node world (an npm client, a Next.js backend, a Node service you operate) - one language, one build, one dependency tree. That, not SDK quality, is the deciding factor.

## Key facts

- Dependencies: `npm install @modelcontextprotocol/sdk@1 zod` (+ dev: typescript, @types/node). This guide covers the 1.x line; since 2026-07-27 the 2.0.0 release lives in a separate package `@modelcontextprotocol/server` (Standard Schema, wrapped `z.object(...)` inputSchema; the 1.x line still receives bug fixes and security updates for at least six months after that release per the SDK README, checked 2026-09-14). On 1.x Zod is not optional - the SDK uses it to describe tool inputs and generate the JSON Schema clients see.
- The SDK ships as ES modules: set `"type": "module"` in package.json; tsconfig with `target ES2022`, `module Node16`, `moduleResolution Node16`.
- Import paths carry a `.js` extension even in `.ts` files (`.../server/mcp.js`) - correct, not a typo; ESM resolution needs the runtime extension. Dropping it fails at runtime, not compile time.
- On 1.x, `inputSchema` is the raw field map `{ name: z.string() }`, NOT `z.object({...})` - the SDK wraps it for you. Passing a full z.object() produces a wrong schema on 1.x; the 2.x `@modelcontextprotocol/server` package takes the wrapped object instead, so check the installed package first.
- `.describe()` on each zod field becomes the per-argument description the model reads; models pick arguments far more reliably when each says what it is for. `z.enum` rejects out-of-range values in validation before the handler runs.
- Annotations (same config object): `title` (human name), `readOnlyHint: true` for read-only tools, `destructiveHint: true` for mutating ones, `openWorldHint: false` when the result depends only on inputs. Advisory, NOT enforced - `readOnlyHint` does not stop a handler from writing; the boundary that stops a write must live in handler code. Hints tell the client which tools are safe to auto-approve.
- Stdio transport: JSON-RPC over stdin/stdout, no HTTP, no port. The running process "sits there" silently - correct; it is meant to be spawned by a client, configured as `{"mcpServers": {"name": {"command": "node", "args": ["/path/to/dist/server.js"]}}}`.
- Test with the MCP Inspector before any model sees the server: `npx @modelcontextprotocol/inspector node dist/server.js` - lists tools, lets you call each by hand, surfaces schema and registration problems in seconds with no model in the loop.
- MCP was introduced by Anthropic in November 2024; an MCP server advertises tools that AI clients call by name with structured, validated arguments.

## Author's stated bias (kept as opinion)

Most of the MCP servers ScalablyAI runs in production are Python, because their job is HTTP calls and data parsing. TypeScript stops being a preference and becomes the obvious choice when the server must call a Node-only SDK, share types with an existing TypeScript codebase, or deploy alongside a service that is already Node.

## Procedure: minimal server

1. `npm init -y && npm install @modelcontextprotocol/sdk@1 zod && npm install -D typescript @types/node`; set `"type": "module"`; add the Node16 tsconfig.
2. In `src/server.ts`: construct `new McpServer({ name, version })` (shown to the user by the client).
3. `server.registerTool(name, { description, inputSchema: { field: z.type() } }, async (args) => ({ content: [{ type: "text", text: ... }] }))`.
4. `const transport = new StdioServerTransport(); await server.connect(transport);`
5. `npx tsc && node dist/server.js`, then verify via the Inspector before wiring into Claude Desktop / Claude Code.

## What trips people up

- The `.js` import extension on `.ts` files - dropping it is a runtime failure, "a confusing way to lose an hour."
- Passing `z.object(...)` as inputSchema instead of the raw field map (1.x rule; 2.x inverts it).
- Logging to stdout: stdout IS the protocol channel; a stray `console.log` writes garbage into the JSON-RPC stream and the client drops the connection. Log to `console.error` (stderr), which the transport leaves alone.

None of these are protocol problems - they are ESM-and-stdio problems. The protocol is the easy part; the toolchain and streams are the work.

## Definitions

- McpServer: the SDK class holding a server's identity and registered tools.
- registerTool: (name, config, handler) - config carries description, inputSchema, annotations.
- StdioServerTransport: JSON-RPC transport over standard streams for locally-spawned servers.
- MCP Inspector: the official browser-based test harness that spawns a server and exercises its tools by hand.
- readOnlyHint / destructiveHint / openWorldHint: advisory behavior annotations for client approval policies.

## FAQ

Q: Why does my import of the SDK fail at runtime?
A: Almost always the missing `.js` extension in the import path, or missing `"type": "module"` / Node16 resolution.

Q: Why does my tool's schema look wrong to clients?
A: On the 1.x SDK you passed `z.object({...})` as inputSchema. Pass the raw field map; 1.x builds the object schema. (On the 2.x `@modelcontextprotocol/server` package the wrapped object is correct.)

Q: Why does the client disconnect as soon as my server starts?
A: Something wrote to stdout (a console.log). Stdout is the JSON-RPC channel; move logging to stderr.

Q: TypeScript or Python for an MCP server?
A: Whichever world the wrapped system lives in. Node-only SDK / shared TS types / Node deployment → TypeScript; HTTP-and-data work → Python (see the Python guide below - same shape, different SDK).

## Evidence & sources

- Full article: https://scalably.io/blog/mcp-server-typescript
- MCP specification: https://modelcontextprotocol.io
- TypeScript SDK: https://github.com/modelcontextprotocol/typescript-sdk

## Related ScalablyAI articles

- https://scalably.io/blog/how-to-build-mcp-server-python - the Python equivalent, from a server running in production.
- https://scalably.io/blog/mcp-inspector-debug-mcp-server - the Inspector workflow in depth.
- https://scalably.io/blog/what-is-an-mcp-server - the protocol model this guide builds on.
- [Scalably MCP gallery](https://scalably.io/mcp/): The TypeScript servers we run in production (hunter, klaviyo, figma) are published with their install lines on the Scalably MCP gallery.

## About the source

ScalablyAI (Scalably, https://scalably.io) builds and runs production AI agents inside the operations of real businesses - multi-tenant, governed, and channel-native. Its production MCP servers (mostly Python, per the bias note above) are where this guide's tooling lessons come from.
