How to build an MCP server in TypeScript, from a blank folder to a package people install
Forty lines is a working server. The other four hundred are what makes it one somebody else can run and trust — the transport, the errors, the packaging and the surface a reader reads first.
An MCP server is a small program that answers two questions: what tools do you have and run this one. The official TypeScript SDK handles the protocol — the handshake, the framing, the transport — which leaves you the part that is actually yours: what the tools are, what they reach, and what they say when they fail. This is the whole path, in the order you will walk it, with the places people fall off marked. The same server is also written up in Python and C#.
The minimum server
One file, one tool, stdio. The SDK’s McpServer owns the protocol; registerTool declares a tool with a schema; the transport decides how bytes arrive. Over stdio the client starts your process and talks to it on stdin and stdout, so the only rule that bites is the one nobody reads: never write anything but protocol to stdout. A stray console.log is a broken frame, and the symptom is a client that says the server disconnected.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "issues", version: "1.0.0" });
server.registerTool(
"create_issue",
{
title: "Create an issue",
description: "Creates one issue in an existing project. Fails if the project does not exist; does not create it.",
inputSchema: { project: z.string().describe("Project key, e.g. INFRA"), title: z.string().min(3) },
},
async ({ project, title }) => {
const issue = await api.createIssue(project, title);
return { content: [{ type: "text", text: `Created ${issue.key}: ${issue.url}` }] };
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("issues server ready"); // stderr, never stdoutRun it under any client — claude mcp add issues -- node dist/index.js is the one-liner in Claude Code — and the tool appears. That is the forty lines. Everything from here on is about the difference between a server that works on your machine and one that works on somebody else’s.
Schemas are what the model reads
The input schema is not validation, or not mainly. It is the text the model uses to decide what to pass. z.string() tells it nothing; z.string().describe("Project key, e.g. INFRA") tells it the shape and gives it an example, and the example is what stops it inventing "my-project". Every argument gets a description. Enums get the values. Optional arguments say what happens when they are omitted.
- One tool, one action. A tool that creates or updates depending on a flag is two tools wearing one name, and the model will call the wrong half.
- Name the world, not the code. create_issue, list_open_issues, close_issue — a verb and the thing it touches. Not handle, not run, not process.
- Split reads from writes. Anyone deciding whether to install reads which tools write before anything else, and a read-only credential should not be able to reach a tool that does both.
- Return less. A tool that returns a thousand lines of JSON has spent a thousand lines of the model’s window for one field. Return the fields the next step needs, and offer a page size.
Errors a model can report instead of retry
There are two ways to fail and they produce different agents. A thrown exception becomes a protocol error; the client shows the model "tool failed" and the model does the thing agents do with unexplained failure, which is try again. A result with isError: true and a sentence is a tool answer: the model reads it, knows what happened, and can tell the person or pick another route.
async ({ project, title }) => {
const p = await api.project(project);
if (!p) {
return {
isError: true,
content: [{ type: "text", text: `No project called ${project}. list_projects returns the keys that exist.` }],
};
}
// …
}The rule of thumb: throw for things that are wrong with the call (malformed arguments, a transport problem) and return isError for things that are wrong with the world (not found, not allowed, already exists). And put the failure modes in the description too — "fails if the project does not exist" prevents the call that would have failed.
The same server over Streamable HTTP
Stdio means the client runs your process, which means the client has your code, your dependencies and your credentials on its disk. The moment you want to hold a credential yourself, meter anything, or sell a subscription that means something, the server has to be reachable at an address — and the transport for that is Streamable HTTP: one endpoint, POST for every message, an optional stream back.
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
// stateless: one transport per request, no session to keep
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000);Two things change the moment the server has an address. It needs authentication — a bearer header at the least, OAuth if you are taking somebody else’s users — and it needs to refuse anything that is not a well-formed request before doing any work, because an address is something a stranger can send bytes to. The stdio version had neither problem, because the client was the only thing that could reach it.
Testing it before a model does
The inspector is a browser page that connects to your server the way a client would and lets you call tools by hand. Use it before any model does: a model will hide a bad schema by guessing well, and the guess stops working on the day it matters.
npx @modelcontextprotocol/inspector node dist/index.js
# or, for the HTTP one
npx @modelcontextprotocol/inspector http://localhost:3000/mcp- Read the tool list as a stranger. If a name needs the description to make sense, rename it.
- Call every tool with an argument that does not exist. The answer should be a sentence, not a trace.
- Call a write tool twice with the same arguments. If the second call creates a second thing, say so in the description or make it idempotent.
Packaging it so npx runs it
A client installs a local server by running a command, and the command people expect is npx -y <package>. That works only if the package declares a binary, the binary has a shebang, and the build output is what the binary points at. Three lines in package.json and one at the top of the entry file; a surprising number of published servers miss one of the four and answer "could not determine executable to run".
{
"name": "@you/issues-mcp",
"version": "1.0.0",
"type": "module",
"bin": { "issues-mcp": "dist/index.js" },
"files": ["dist"],
"scripts": { "build": "tsc", "prepublishOnly": "npm run build" }
}
// dist/index.js starts with
#!/usr/bin/env nodeIf the binary name differs from the package name — it usually does — say so wherever you publish, because the install line becomes npx -y --package=@you/issues-mcp issues-mcp and a README that prints the short form prints one that fails.
What the README has to say
The code is finished. The README is a different document with a different reader, and it has to answer four questions in order — before the feature list, because the feature list is what people read after they have decided.
| Question | Where the answer comes from | The bad answer |
|---|---|---|
| Which tools write? | The declared surface, split by reach | A wall of thirty names |
| What credential does it hold, and where? | Environment variables it refuses to start without | "Set your API key" with no scope named |
| What does it cost, and what happens at the edge? | A free tier with a real allowance, then a plan | "Contact us" |
| What happens on the next release? | A changelog and a version the install can pin | Nothing — the new tool is simply live |
None of this is much code. The forty lines at the top are the server. The rest is the difference between a repository with a README and a thing a stranger installs on a Tuesday without reading it, which is what "published" means.