AI
How to build an MCP server in TypeScript, step by step
Build a working Model Context Protocol server with one tool in about forty lines of TypeScript, test it, connect it to a client, and avoid the mistakes that waste an afternoon.
By Raktim Ranjit · Published · 3 min read
Short answer: install the official TypeScript SDK and zod, create an McpServer, register a tool with a schema and a handler, connect it to a stdio transport, then test it with the MCP Inspector before attaching it to a real client. The first version takes about forty lines.
If you want the concepts first, read what an MCP server is. This post is the build.
What do you need before you start?
- Node.js 20 or newer.
- A project with
"type": "module"inpackage.json. - Something small to expose. I will use a lookup of invoice status from a local JSON file so the example has no external dependencies.
mkdir invoice-mcp && cd invoice-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/nodeHow do you write the server?
Create server.ts. The shape is always the same: make a server, register tools, connect a transport.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile } from "node:fs/promises";
const server = new McpServer({ name: "invoice-status", version: "0.1.0" });
server.registerTool(
"get_invoice_status",
{
title: "Get invoice status",
description: "Look up one invoice by its number and return its status and amount due.",
inputSchema: { number: z.string().regex(/^INV-\d{4,}$/) },
},
async ({ number }) => {
const rows = JSON.parse(await readFile("invoices.json", "utf8"));
const row = rows.find((r: { number: string }) => r.number === number);
if (!row) {
return { isError: true, content: [{ type: "text", text: "No invoice with that number." }] };
}
return { content: [{ type: "text", text: JSON.stringify(row) }] };
},
);
await server.connect(new StdioServerTransport());Add a small invoices.json next to it with a couple of rows. Run it with npx tsx server.ts. It will look like it hangs. That is correct. A stdio server waits for a client to speak to it.
Why must you never print to stdout?
This is the mistake that costs the most time. On a stdio transport, standard output is the protocol channel. A stray console.log writes text into the middle of a JSON-RPC stream and the client drops the connection with a parse error that does not mention your log line.
Log to standard error instead: console.error("loaded"). Many clients show stderr in a log pane, so you lose nothing.
How do you test it?
Use the MCP Inspector, a small web UI that acts as a client.
npx @modelcontextprotocol/inspector npx tsx server.tsOpen the URL it prints, connect, open the Tools tab, and call get_invoice_status with a number. You see the raw request and response. If a field is wrong, you see it here before an AI model gets involved and makes the problem harder to read.
How do you connect it to a client?
Most desktop and coding clients read a JSON config that lists servers and the command to start each one. The entry looks like this.
{
"mcpServers": {
"invoice-status": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/server.ts"],
"cwd": "/absolute/path/to"
}
}
}Use absolute paths. A client starts the process from its own working directory, so a relative invoices.json will fail unless you set cwd.
How do you write tools a model uses well?
- Name and describe for the decision. The description should say when to use the tool and what it returns.
- Constrain inputs. The regex above rejects garbage before your code runs, and the model sees the constraint.
- Return errors as results. Set
isError: truewith a readable message. A thrown exception becomes a protocol error the model cannot reason about. - Keep output small. Return the fields needed, not a whole table. Large results eat the context window.
- Prefer fewer, bigger tools. One tool that answers a real question beats five thin wrappers.
How do you run it over HTTP instead?
Stdio is right for a tool that runs on the same machine as the client. If several people need the same server, use the SDK's streamable HTTP transport behind your normal web stack. At that point you own authentication, because anyone who can reach the endpoint can call your tools. Put it behind the same auth you use for any internal API and issue per-user tokens.
Before you expose anything that writes data, read MCP security and prompt injection. A read-only tool is a safe place to learn. A tool that sends email or deletes rows needs an approval step.
What goes wrong most often?
- Output to stdout breaks the stream (covered above).
- A schema that says a field is a string while the code expects a number, so every call fails validation.
- A client caches the tool list. After you change a tool, restart the client or reconnect.
- Relative paths that work in your terminal and fail inside the client.
References
Author
Raktim Ranjit is a software engineer and the founder of NodeDR Infotech. He builds and maintains the software described here.