← All posts

Guide

Build your own MCP server: the minimal version, and the parts everyone gets wrong

Build your own MCP server: the minimal version, and the parts everyone gets wrong

There are a hundred guides on connecting an MCP server to Claude Code. There are far fewer on writing one, and most of those stop at the point where a tool returns a string — which is roughly where the interesting problems start. The protocol is the easy half. The hard half is that your tool has to compete for attention with every other tool the model can see, using a description that is read by a language model rather than by a human, and its output has to land in a context window that is already full.

This is the build guide: an empty directory to a tool your agent actually calls, then the parts that decide whether it is useful or just present.

Verified on 2026-08-02 against @modelcontextprotocol/sdk 1.30.0 (npm latest, published 2026-07-27), Node 22.22.2, and Claude Code 2.1.221. Protocol references are to the MCP specification revision 2026-07-28 at modelcontextprotocol.io, with a caveat about SDK lag noted below. Every code sample here was run. The three server files typecheck under tsc --noEmit with strict, and each was exercised by an SDK client over its own transport; the outputs shown are copied from those runs, not written by hand.

First: should this be an MCP server at all?

An MCP server earns its keep when the agent needs something it cannot already reach. Your agent can already read files, grep, and run shell commands. Wrapping git log in an MCP tool buys you nothing but a slower path to the same answer and a permanent tax on the context window. What justifies a server is a system on the other side of a network boundary or a credential: an internal catalog, a ticketing system, a database, a device.

The other fork is whether you want a server at all rather than a skill — a folder of instructions the agent reads when relevant. If what you are adding is knowledge or a procedure, that is a skill. If it is a capability, that is a server. Our skills vs plugins vs MCP breakdown draws that line properly; the rest of this guide assumes you landed on "server".

Pick a transport, and understand what you just signed up for

The current spec defines exactly two transport bindings. The choice looks technical and is actually operational — it decides whether you have an auth story and a deployment.

 stdioStreamable HTTP
How it runsThe client launches your program as a subprocess and talks newline-delimited JSON-RPC over its standard streamsEach message is an HTTP POST to one endpoint; replies come back as JSON or a request-scoped SSE stream
Who can reach itOnly the user whose machine it runs onAnyone who can reach the URL
AuthNone needed. It inherits the user's environment and filesystem accessYours to build. OAuth or a bearer token, plus Origin validation and localhost binding if it is local
DeploymentNone. Ship a file or an npm packageA service you now operate, monitor and patch
SecretsThe user's own, from their envYours, held server-side for everyone
Use whenThe tool needs local resources, or each user brings their own credentialsA team shares one integration, or the server must sit next to the system it wraps

Start with stdio. Not because it is a toy — the Playwright and filesystem servers are stdio — but because the moment you go HTTP you have inherited authentication, transport security and an uptime obligation for a thing whose whole job was to save you fifteen seconds of copy-paste. Go HTTP when you have a reason: one integration shared by a team, or a server that has to live inside a network your laptops cannot see.

Two footnotes worth having. The older HTTP+SSE transport, with its separate GET event stream, is the deprecated legacy path; Claude Code still accepts --transport sse, but nothing new should be built on it. And if you do bind an HTTP server to localhost, validate the Origin header — otherwise any web page the user has open can POST to your MCP endpoint. That is four lines, shown below.

stdio agent spawn your server no port · no auth · no deploy user's own credentials Streamable HTTP agent POST /mcp you own auth · TLS · uptime your credentials, for everyone
Same protocol either way. The difference is everything that surrounds it.

A working server, from an empty directory

The running example is a service catalog: a lookup the agent genuinely cannot do from the repository, because the answer lives somewhere else. Three commands:

mkdir service-catalog && cd service-catalog
npm init -y && npm pkg set type=module
npm i @modelcontextprotocol/sdk zod

That type=module is not decoration. The SDK is ESM-only; without it Node tries to parse your file as CommonJS, prints a MODULE_TYPELESS_PACKAGE_JSON warning, and the client sees nothing but a closed pipe.

Some data for the server to serve — services.json:

[
  { "name": "checkout-api",   "owner": "payments", "runbook": "https://wiki.internal/runbooks/checkout-api",   "oncall": "@payments-oncall", "tier": 1 },
  { "name": "cart-worker",    "owner": "payments", "runbook": "https://wiki.internal/runbooks/cart-worker",    "oncall": "@payments-oncall", "tier": 2 },
  { "name": "image-resizer",  "owner": "media",    "runbook": "https://wiki.internal/runbooks/image-resizer",  "oncall": "@media-oncall",    "tier": 3 }
]

And the whole server — server.ts:

import { readFileSync } from 'node:fs'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'

type Service = { name: string; owner: string; runbook: string; oncall: string; tier: number }
const services: Service[] = JSON.parse(readFileSync(new URL('./services.json', import.meta.url), 'utf8'))

const server = new McpServer({ name: 'service-catalog', version: '1.0.0' })

server.registerTool(
  'find_service',
  {
    description: 'Look up an internal service by name.',
    inputSchema: { query: z.string() },
  },
  async ({ query }) => ({
    content: [
      { type: 'text', text: JSON.stringify(services.filter((s) => s.name.includes(query))) },
    ],
  }),
)

await server.connect(new StdioServerTransport())

That is a complete, spec-compliant MCP server. Node 22.18 and later strip TypeScript types on the fly, so node server.ts runs it directly with no build step — which is how it was run here. If you start it by hand it will sit there silently waiting for JSON-RPC on stdin; that is success, not a hang.

Three details in twenty lines are worth naming. registerTool is the current API — the older server.tool(...) overloads still work but are marked deprecated in the 1.30.0 type definitions. The inputSchema is a plain object of Zod schemas, not a Zod object, and the SDK converts it to the JSON Schema the model sees. And your handler's argument is already validated and typed: if a client calls find_service with no query, the SDK rejects it before your code runs, with -32602: Input validation error.

The HTTP version, for when you need it

Same server object, different transport. This one was run too, and answered an SDK client over the wire:

import { createServer } from 'node:http'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'

function buildServer() {
  const server = new McpServer({ name: 'service-catalog-http', version: '1.0.0' })
  server.registerTool('ping', { description: 'Return pong.', inputSchema: {} }, async () => ({
    content: [{ type: 'text', text: 'pong' }],
  }))
  return server
}

createServer(async (req, res) => {
  const origin = req.headers.origin
  if (origin && new URL(origin).hostname !== 'localhost' && new URL(origin).hostname !== '127.0.0.1') {
    res.writeHead(403).end()
    return
  }
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
  res.on('close', () => transport.close())
  await buildServer().connect(transport)
  await transport.handleRequest(req, res)
}).listen(8931, '127.0.0.1', () => console.error('listening on http://127.0.0.1:8931/mcp'))

Note the shape: a new server and transport per request, because sessionIdGenerator: undefined means stateless mode and there is no session to hold them across calls. Reusing one transport across requests is the mistake to avoid here — it accepts initialize and then fails the follow-up with a 500, which reads like a transport bug and is not one. Note also console.error, not console.log: see below.

Register it with Claude Code, at the right scope

claude mcp add --scope local service-catalog -- node /abs/path/to/server.ts
claude mcp list

Flags before the name, then a bare --, then the command to run. The scope decides who sees the server and which file it lands in: local (the default) is you, in this project, stored in ~/.claude.json; user is you, everywhere; project writes a committable .mcp.json at the repo root. For a server you are actively writing, use local — a project-scoped server shows up as ⏸ Pending approval until you start a session and approve it, which is a security feature and, while you are iterating, a confusing one. Move it to project once it works and you want the team on it.

The file it writes for project scope:

{
  "mcpServers": {
    "service-catalog": {
      "type": "stdio",
      "command": "node",
      "args": ["/abs/path/to/server.ts"],
      "env": {}
    }
  }
}

Once connected, your tool is addressable as mcp__service-catalog__find_servicemcp__, the server name you chose, two more underscores, the tool name. That is the string you need for permission rules, --allowedTools, a subagent's tools list, or a hook matcher. Renaming your server renames every one of those.

The tool description is a prompt, not documentation

This is where homemade servers fail, and it is not a protocol problem. Your description is the only thing the model reads when deciding whether this tool is relevant to the task in front of it. It is never read by a human at runtime. So the question it must answer is not "what does this tool do" but "when should I reach for this instead of something else".

The version in the minimal server above is the version almost everyone writes:

description: 'Look up an internal service by name.'

Accurate, useless. It does not say what comes back, so the model cannot tell whether this answers the question. It does not say when to use it, so faced with "who owns checkout-api?" the model will cheerfully grep the repo instead. Here is the same tool, rewritten as a prompt — this is the exact text the server serves:

description:
  'Search the internal service catalog for a service by name or name fragment. Returns the ' +
  'owning team, runbook URL, on-call handle and tier (1 = most critical). Use this when a ' +
  'task names a service and you need to know who owns it, where its runbook is, or how risky ' +
  'a change to it is. The catalog is not in the repository, so grepping the code will not ' +
  'answer these questions. Read-only; safe to call speculatively.',

Four things changed, and each one is doing work:

Two mechanical points alongside the prose. Put a .describe() on every parameter — those strings travel into the JSON Schema and are read as carefully as the tool description, and they are where you put format examples ('Service name or a fragment, e.g. "checkout" or "checkout-api". Case-insensitive.'). And set the annotations: readOnlyHint, idempotentHint, destructiveHint, openWorldHint. They are hints, not enforcement — the spec is blunt that annotations from an untrusted server should not be believed — but clients use them to decide how loudly to ask permission, and a read-only tool that fails to say so is a tool the user gets nagged about.

Name the tool for search, too. find_service beats lookup and svc, and a consistent prefix across a server's tools (catalog_find, catalog_list_owners) means one search matches the whole family. Which brings us to the reason naming now matters more than it used to.

Your tools may not be in the context window at all

The old mental model — every connected server's full tool schemas sit in the system prompt from the first turn — is no longer how Claude Code works. Tool search is on by default. MCP tool definitions are deferred; at session start the model gets only your tool names and your server's instructions, and it calls a search tool to pull in full definitions when a task looks like it needs them. Only the tools it actually uses enter the context.

The mechanism underneath is the Claude API's tool search tool, which reports over 85% less context spent on tool definitions in a typical multi-server setup — roughly 55k tokens of definitions replaced by the three to five tools a request needs. It searches tool names, descriptions, argument names, and argument descriptions. That is the corpus. If none of those fields contains a word your user would use, your tool is not found, and "not found" is indistinguishable from "not installed".

Three consequences for you as an author:

The instructions string is the second argument to the constructor:

const server = new McpServer(
  { name: 'service-catalog', version: '1.0.0' },
  {
    instructions:
      'This server answers questions about internal services: who owns them, where the runbook ' +
      'is, and how critical they are. The catalog lives outside the repo, so these questions ' +
      'cannot be answered by reading files. Look a service up before editing code that touches it.',
  },
)

Users can turn deferral off with ENABLE_TOOL_SEARCH=false, or switch to a threshold with ENABLE_TOOL_SEARCH=auto (load schemas upfront while they fit in 10% of the context window, defer the overflow). A server can also opt out for itself with "alwaysLoad": true in its .mcp.json entry, or per tool with "anthropic/alwaysLoad": true in the tool's _meta. Reach for that only for a tool the agent needs on literally every turn — you are spending everyone's context to save one search.

Return something the model can use

The minimal server returns JSON.stringify(hits). That works, and it is the wrong default. A raw JSON dump makes the model parse structure out of prose it cannot re-read cheaply, and it scales badly: MCP output is capped, and results that overflow get replaced with a file reference the model then has to go and read.

Return both a human-shaped summary and a structured payload:

outputSchema: { services: z.array(z.object(serviceShape)) },
// ...
return {
  structuredContent: { services: hits },
  content: [
    {
      type: 'text',
      text: hits
        .map((s) => s.name + ' — owner ' + s.owner + ', tier ' + s.tier + ', on-call ' + s.oncall)
        .join('\n'),
    },
  ],
}

Declaring an outputSchema makes the SDK validate what you return — a real safety net when your handler grows — and publishes the shape in tools/list, so the model knows the fields before it calls. The content array stays the thing the model reads. One line per result, with the fields inline, beats a wall of pretty-printed JSON at the same token cost.

The size limits are worth knowing before you hit them, all Claude Code defaults:

The better move is usually not a bigger ceiling. It is pagination, a limit parameter, or a summary with a second tool to drill in. A tool that returns 2,000 rows because someone asked a broad question has spent the context the model needed to do anything with them.

Errors that tell the model how to recover

MCP has two failure channels and they are not interchangeable. Throwing from your handler produces a JSON-RPC protocol error. Returning a normal result with isError: true produces a tool result the model reads like any other. The difference in practice: a protocol error says "that request was invalid", a tool error says "here is what happened, try this instead".

Most failures in a real server are the second kind. No such service. Query too short. Rate limited, retry in 30 seconds. Those are not malformed requests; they are outcomes, and the model can act on them if you let it. This is the miss branch of the catalog server, and the output below is copied from an actual call:

if (hits.length === 0) {
  return {
    isError: true,
    content: [
      {
        type: 'text',
        text:
          'No service named "' + query + '" is in the catalog. It contains: ' +
          services.map((s) => s.name).join(', ') +
          '. Retry with one of those names or a fragment of one; do not guess an owner.',
      },
    ],
  }
}
--- MISS: {
  "content": [
    {
      "type": "text",
      "text": "No service named \"nope\" is in the catalog. It contains: checkout-api, cart-worker, image-resizer. Retry with one of those names or a fragment of one; do not guess an owner."
    }
  ],
  "isError": true
}

Compare with Not found. The message above contains the valid values, an instruction for the retry, and a prohibition on the specific wrong move that follows a failed lookup — inventing an owner. The model gets one shot to recover per call; make the shot cheap. The rule of thumb: every error message should contain either the valid inputs or the next action. If it contains neither, the model's only options are to give up or to guess.

Keep exceptions for the genuinely exceptional — your database is unreachable, your config is missing. And never put a secret, an internal hostname or a raw stack trace in an error string: tool results land in the transcript, and on a shared server they land in everyone's.

One tool-design note that pairs with this. If a tool's permission prompt is the point — a consent step, an access grant — set _meta["anthropic/requiresUserInteraction"]: true on its tools/list entry. Claude Code then prompts on every call regardless of permission mode, and does not offer "don't ask again". It is the one way to guarantee a human actually agreed.

When the model never calls your tool

The symptom that brings people back to this page. Work down the list; it is ordered by how often each one is the answer.

  1. Is it connected? claude mcp list is the first and most often the last step. ✔ Connected means the server started and listed its tools. ✘ Failed to connect usually means your program crashed on startup — run the exact command from the config in your own shell and read the error. A missing "type": "module", a bad path, or an unset env var all show up here as a bare MCP error -32000: Connection closed, which tells you nothing until you run the command yourself.
  2. Is it waiting for you? ⏸ Pending approval means the server is project-scoped and you have not approved it. Start a session, or run claude mcp reset-project-choices if you rejected it once and regretted it.
  3. Did it connect but list nothing? Run /mcp in a session and select the server. An empty tool list usually means a missing environment variable made your registration code bail. Pass it with --env KEY=value.
  4. Was it too slow? The startup timeout is 30 seconds; a first npx download can blow through it. MCP_TIMEOUT=60000 claude.
  5. Did the model try and get blocked? In headless mode, claude -p "..." --output-format json returns a permission_denials array with the exact tool name and arguments it attempted. That is the cleanest way to confirm the model did pick your tool and something downstream stopped it. It is also how the canonical tool name in this guide was confirmed.
  6. Can it be found? With tool search on, a tool whose name, description and parameter descriptions share no vocabulary with the request will not surface. Test it directly: ask "use the service catalog to find who owns image-resizer". If naming the server makes it work and the natural phrasing does not, your description is the bug, not your code.
  7. Is your schema even legal? A tool whose input schema has anyOf, oneOf or allOf at the root is not something the Claude API accepts. Recent Claude Code flattens it and describes the branches in the tool description instead, but older versions skipped the tool entirely and said nothing. Nest your combinators inside properties and validate the combination server-side.

On the perennial "never write to stdout in a stdio server" advice: the reasoning is right — stdout is the wire, and anything you print there is fed to a JSON-RPC parser — but the failure is less dramatic than the folklore. Adding a console.log line to the server above, then registering it, both the SDK client and Claude Code 2.1.221 skipped the stray line and connected fine. Treat it as the rule it is (console.error, always) without assuming a stray print is your bug. It probably isn't; check the list above first.

Two tools beyond the CLI are worth knowing. The MCP Inspector (npx @modelcontextprotocol/inspector node server.ts) gives you a UI that lists and calls your tools with no model in the loop, which separates "my server is broken" from "my description is bad". And a fifteen-line script using the SDK's own Client over StdioClientTransport — connect, listTools(), callTool(), print — is a regression test you can run on every save. That is what produced the verified outputs in this article.

One caveat about versions

Worth knowing before you read the spec too literally. The MCP specification's current revision, 2026-07-28, is a significant rework: it removes the initialize handshake in favour of per-request metadata and adds a server/discover method, and it calls everything up to 2025-11-25 "legacy". The TypeScript SDK's main branch is a v2 that implements it.

But npm install @modelcontextprotocol/sdk today gets 1.30.0, whose LATEST_PROTOCOL_VERSION is 2025-11-25 — verified by reading the installed package and by watching a live handshake negotiate exactly that. Everything in this guide is written against that reality, and none of it changes: registerTool, instructions, isError and the transports are the same on both sides of the revision. Just do not be surprised when the spec describes a handshake your SDK does not perform, and check which major version you are on before porting a snippet from the spec.

Frequently asked questions

How do I build an MCP server in TypeScript?

Install @modelcontextprotocol/sdk and zod, create an McpServer, register one tool with registerTool(name, config, handler), and connect it to a StdioServerTransport. Twenty lines. Register it with claude mcp add <name> -- node server.ts.

stdio or HTTP?

stdio if it runs on the user's machine: no port, no auth, no deployment, and it inherits their credentials. Streamable HTTP if a team shares one integration or the server must sit next to the system it wraps — at the cost of owning authentication, TLS and uptime. The older HTTP+SSE transport is the deprecated legacy path.

Why does the model never call my tool?

Check, in order: claude mcp list says connected; the server is not stuck on ⏸ Pending approval; the tool list is not empty; and the description actually says when to use the tool. With tool search on, a description that shares no words with the request will not be found at all.

How should an MCP tool report an error?

Return isError: true with a message naming the valid inputs or the next action — that reaches the model and it can recover. Throw only for genuinely exceptional failures, which become protocol-level JSON-RPC errors.

What is the instructions field for?

Optional natural-language guidance about the server as a whole. Claude Code loads it into every session and truncates it at 2KB. With tool search deferring your schemas, it is how the model decides your server is worth searching.

Do I need a build step?

No. Node 22.18 and later strip TypeScript types on the fly, so node server.ts works. Ship a compiled build if you publish to npm; for a server your team runs from a repo, the source is fine.

Where Backgrind fits

A server you wrote is the one most likely to want a human in the loop — the tool that files the ticket, restarts the job, writes to the table. That is a permission prompt, and a permission prompt only works if you notice it. Backgrind runs your real Claude Code CLI in an always-on-top overlay and turns those prompts into ambient toasts you can approve with one keystroke, so a tool call waiting on your sign-off does not quietly stall a run for forty minutes. Build the server; keep the loop closed. Then watch it work in the live demo.