Publishing 31 Aug 2026 · 5 min read

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.

MR mcprush team · The people who run the gateway

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#.

Where your code sits in a turn: after the model has chosen, before the result goes back into the window. Everything else is the SDK and the client.Where your code sits in a turn: after the model has chosen, before the result goes back into the window. Everything else is the SDK and the client.
Where your code sits in a turn: after the model has chosen, before the result goes back into the window. Everything else is the SDK and the client.

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 stdout
The whole of a working server. Logs go to stderr; stdout belongs to the protocol.

Run 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.
The split a reader sees. Two write tools out of six decide the whole risk of the install, so they had better be the ones you meant.The split a reader sees. Two write tools out of six decide the whole risk of the install, so they had better be the ones you meant.
The split a reader sees. Two write tools out of six decide the whole risk of the install, so they had better be the ones you meant.

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.` }], }; } // … }
A failure that names the next step. The model reads this; it cannot read a stack trace.

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);
Stateless is the shape to start with. Sessions are a feature you add when you need a server-initiated stream, not a default.

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
What a client sees: the tool list exactly as declared, and every call with its result.
  • 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 node
The four lines that make "npx -y @you/issues-mcp" a working install line in every client that starts processes.

If 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.

QuestionWhere the answer comes fromThe bad answer
Which tools write?The declared surface, split by reachA 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 pinNothing — the new tool is simply live
The first row can be checked by a tool; the other three are yours to write, and the second is the one most READMEs skip.

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.