You have wired up other people's MCP servers. The GitHub one, the Atlassian one, maybe a Postgres connector somebody on the team found in a repo. You have never built your own, because building one sounds like a project: a framework to learn, a service to stand up, a thing to keep alive. So the internal tool you wish Claude could reach, the one that would save your team an hour a day, stays a script only you can run and a tab you copy and paste from.
Here is the part the tutorials bury. An MCP server is a thin wrapper over functions you already have. Model Context Protocol (MCP) is the open standard Claude uses to read from and write to outside systems, and a server that exposes one of your internal tools is closer to a single afternoon than a sprint. The genuinely hard part is not the code. It's the few sentences of description text that decide whether Claude ever picks your tool at all. A study of 10,831 MCP servers found that standard-compliant tool descriptions got the tool selected 72% of the time, against a 20% random-selection baseline. A separate benchmark graded 41,902 servers and handed an A to 0.5% of them, with missing descriptions the single most common failure.
This post builds a working server from scratch, connects it to Claude, and then spends most of its length on the part that earns the title: writing tool descriptions the model can route to. I will also tell you when not to build your own, because for some integrations a maintained connector is the right call, and I run several of those in production. If you are deciding whether that server should graduate beyond a local utility, the MCP servers in production guide covers ownership, auth, and protocol vocabulary in more depth.
What an MCP server is under the hood
If the build is going to feel small, it helps to see how little a server actually is. Strip away the branding and an MCP server is a small program that does three things: it tells a client what it can do, it waits for requests, and it answers them in a structured shape. The official spec defines three kinds of capability a server can expose. Tools are functions the model can call. Resources are data the application can read. Prompts are templates a user can invoke. For a first build you care about exactly one of these. Tools. Tools are the only primitive the model decides to use on its own, which is why they hold both the power and the difficulty.
The transport underneath is plain JSON-RPC. Your server reads a request, runs a function, returns a result. That is the whole contract. When people picture infrastructure here, they are picturing the wrong layer, because the framework is about twenty lines and the thing it wraps is a function you probably already wrote.
I felt the payoff of this as a consumer first. When I wired Claude Code into our Jira, GitHub, and Confluence through MCP, a job that used to eat about two hours of my morning, chasing a ticket through its linked tickets and back out to the Confluence pages that explained them, came back in roughly two minutes. The pieces were scattered across half a dozen people's tickets and docs. MCP let one tool assemble the whole picture in a single pass. That is what a good server buys you: the model stops asking you to fetch things and starts fetching them itself.
Build it: a working server in about thirty lines
Here is the thin wrapper made concrete: a complete server that exposes one tool. It takes a project key and returns who is on call for it, the kind of internal lookup that lives in a script nobody else on the team can run. Swap the lookup for whatever your team keeps re-deriving by hand.
Install the SDK and a schema library. As of May 2026 the current TypeScript SDK is @modelcontextprotocol/sdk version 1.29.0:
npm install @modelcontextprotocol/sdk zod@3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";
const server = new McpServer({ name: "on-call", version: "1.0.0" });
server.registerTool( "get_on_call_engineer", { description: "Look up who is currently on call for a project and return their " + "name and Slack handle. Use when someone asks who to page, who " + "owns an active incident, or who is on call right now.", inputSchema: { projectKey: z.string().describe("Project key, e.g. PAY or WEB"), }, }, async ({ projectKey }) => { const engineer = await lookupOnCall(projectKey); // your existing function return { content: [ { type: "text", text: `${engineer.name} (${engineer.slack})` }, ], }; },);
const transport = new StdioServerTransport();await server.connect(transport);That's the server. McpServer holds the registry, registerTool adds one tool with a name, a description, and a Zod input schema, and StdioServerTransport connects it to the client over standard input and output. No web server. No port. No framework beyond the SDK itself. The shape here follows the official build-server guide; compile it with tsc (or run it directly with tsx), and the config and commands below assume the resulting on-call-server.js.
In stdio mode the server talks to the client over standard output, so anything you print to stdout corrupts the JSON-RPC stream. Use console.error for logging, never console.log. It is the first thing that breaks for almost everyone, and the failure it produces looks nothing like its cause.
Four steps take it from a file to a tool Claude reaches for:
Write the tool
One registerTool call: a name, a description, a Zod input schema, and a handler that calls a function you already have.
Run it over stdio
Connect a StdioServerTransport. The client launches your server as a subprocess, so there is no port and nothing to deploy.
Register it with the host
Add the command to Claude Desktop's claude_desktop_config.json, or run claude mcp add on-call -- node on-call-server.js for Claude Code.
Test before you trust it
Run npx @modelcontextprotocol/inspector node on-call-server.js to call the tool directly and watch the request and response, without going through Claude.
The Claude Desktop registration is a few lines of config. Use an absolute path, because the host launches the command from a working directory you do not control:
{ "mcpServers": { "on-call": { "command": "node", "args": ["/absolute/path/to/on-call-server.js"] } }}For Claude Code, claude mcp add does the same thing, and --scope project writes a .mcp.json you can commit so the whole team gets the server. Where the tool's permissions live is a separate decision, and it belongs in configuration that fails closed, not in a prompt; I worked through that routing question in the decision tree for CLAUDE.md, settings, skills, and hooks.
Prefer Python? The shape is identical. FastMCP turns a decorated function into a tool, where the type hints become the input schema and the docstring becomes the description. Same protocol, same result, fewer imports.
The hard part is the tool description
Read that server again and notice where the only judgment call lives. Not the transport. Not the handler. The description string. That sentence is the entire interface Claude uses to decide whether to call your tool, and it's the part the code can't help you with.
The model never sees your implementation. It sees a name, a description, and a schema, and from those alone it decides which tool fits the request, or whether to reach for one at all. Get the description wrong and the tool you built is invisible. It sits in the registry, correct and untouched, while Claude answers from memory or picks a worse tool that happened to describe itself better.
The data on this is blunt. The study of 10,831 servers I mentioned at the top measured tool selection against description quality and found a better than three-to-one gap when it tested selection on a model harness: 72% for standard-compliant descriptions, 20% for random selection among five. The harness was not Claude, but the routing surface is the same everywhere, because the client picks a tool from the name, the description, and the schema, and nothing else. The same study found that 73% of servers waste the description by repeating the tool's own name inside it. And the quality problem is close to universal. One analysis of 856 tools across 103 servers catalogued description smells, and the ToolBench benchmark graded 41,902 servers on tool quality, where definition quality counts for half of a local server's score. The picture is not close:
So what does a description Claude can route to look like? Three habits carry most of the gain.
Say what the tool does and when to use it, not what it is. The on-call tool's description names the trigger conditions out loud: who to page, who owns an incident, who is on call right now. Those phrases are what a user's request gets matched against. Compare a real description to a lazy one and the difference is the whole game:
// Invisibledescription: "Engineer lookup tool"
// Reachabledescription: "Look up who is currently on call for a project and return " + "their name and Slack handle. Use when someone asks who to page, " + "who owns an active incident, or who is on call right now."Keep it lean. Claude Code truncates tool descriptions at roughly 2KB and, with tool search on, loads tool names before it loads full schemas, so the routing-critical text has to come first and stay short. There is a real tension here, and it cuts against writing more: the same analysis found that padding each tool with maximal detail to resolve every smell raised execution steps by 67%, because the extra text eats the context window. Describe enough to route, not everything you know. Precision beats volume.
Disambiguate from the neighbors. If two tools could plausibly answer the same request, the description is where you tell them apart. A tool named search competing with three other search tools wins or loses on the clause that says what makes it different.
None of this is hard in the way that distributed systems are hard. It's design discipline. ToolBench's own write-up put it plainly: the failures are not difficult to fix, they are discipline issues. Which is exactly why getting it right is an edge. Almost nobody does.
stdio or Streamable HTTP: why your first server is local
You may have noticed I never mentioned a port, a URL, or authentication. That is because the server above uses the stdio transport, and stdio is the reason a first build is an afternoon instead of a sprint.
The current spec, dated 2025-11-25, defines two standard transports. stdio runs your server as a subprocess of the client: it starts when Claude starts, talks over standard input and output, and needs no network, no auth, and no deployment. Streamable HTTP runs your server as an independent service that handles many clients over HTTP, which is what you want for a remote or shared server, and it is a different amount of work.
| Dimension | stdio | Streamable HTTP |
|---|---|---|
| Runs as | Subprocess of the client | Independent web service |
| Setup | A transport object, a few lines | Server, endpoint, hosting |
| Auth | None, local trust | OAuth 2.1, PKCE, consent flows |
| Best for | Your own internal tools, one machine | Remote, multi-user, shared |
| First build | Start here | Grow into it |
This is where the thesis earns its scope. For a local tool that runs where you run Claude, the transport really is a few lines and the description really is the hard part. The moment you expose that server over the network, the protocol stops being thin. The MCP authorization spec expects HTTP servers to implement OAuth 2.1 with PKCE and several supporting RFCs, and the maintainers have written openly about the transport's rough edges at that scale. So the honest version of the advice is narrow on purpose: build your first server local, and reach for HTTP only when the server has to answer to something beyond your own machine. Plenty of internal tools never do.
When not to build your own
Building is the right default for your own tools. It is the wrong default for someone else's. When the system on the other side is GitHub, Jira, or Confluence, a maintained connector is almost always the better call, and the reason is the same reason building your own is cheap. The description and the surface are the work, and a maintained connector has already done that work and keeps doing it.
I run the Atlassian connector in production across a company-wide Claude Code rollout. When the MCP transport changed underneath it, from the older HTTP-plus-SSE transport to Streamable HTTP, the connector absorbed the cutover with zero action on my end. That is what you are buying from a maintained server: somebody else tracks a spec that moves on a dated cadence (three revisions between November 2024 and November 2025), and somebody else owns the security surface.
That surface is real, and it is the strongest argument against rolling your own. The tool description Claude routes on is also a place an attacker can hide instructions. Invariant Labs demonstrated tool poisoning, where a malicious server embeds directives in a description that the model reads and the user never sees. OX Security traced more than a dozen CVEs to a single design choice in the reference stdio implementation that propagated across the ecosystem. None of that means do not build. Building small does not make the description field un-injectable, but it shrinks the blast radius to one narrow tool and keeps the text you are trusting under your own review instead of a stranger's. So build small, expose only what the tool needs, and never treat a server, yours or a vendor's, as set-and-forget. Before any server goes anywhere near production, walk it through the re-audit playbook.
The rule I use is short. Build for the tools that are yours and that no one else will ever package. Adopt for the vendor systems that a thousand other teams also need. The Jira, GitHub, and Confluence setup guide covers the adopt side. This post is the build side, and the build side is wider than people think: a UX research corpus your whole team can query is a purpose-built MCP server too, and nobody is going to ship that one for you.
A few questions I get about building servers
Once the build stops looking like a project, these are the questions that come up next.
Should I build my MCP server in Python or TypeScript? Either works. The SDKs are at parity for exposing tools, so use the language your team already maintains. This guide is TypeScript; the Python version with FastMCP is just as short.
Do I need a framework like FastMCP to build an MCP server? No third-party framework is needed. The official SDK is the only dependency, and registerTool (or the @mcp.tool() decorator in Python) is the whole surface.
How do I connect my MCP server to Claude Desktop or Claude Code? Claude Desktop reads the mcpServers block in claude_desktop_config.json, with an absolute path. Claude Code uses claude mcp add, and project scope commits a shared .mcp.json for the team.
Should a first MCP server use stdio or Streamable HTTP? stdio. No port, no hosting, no auth. Move to Streamable HTTP only when the server has to be remote or shared.
How do I write a tool description Claude will use? Say what it does and when to use it, name the trigger conditions, do not repeat the tool name, and keep it under the 2KB Claude Code truncates at. Then test the routing with the MCP Inspector before you trust it. If you are weighing a server against a reusable instruction set instead, the skill-versus-MCP boundary is worth reading first: MCP is the input, a skill is the playbook.
Build the one you keep copy-pasting
Pick the tool you re-derive by hand every week. The on-call lookup. The deploy-status check. The customer-tier query buried in a script only you can run. That is your first server. The build is an afternoon, and the description is the part worth a second draft, and a third.
The numbers say good descriptions get chosen three times as often as careless ones, and almost nobody writes them. That gap is your advantage, and it is discipline rather than difficulty. Once the server is running and Claude is reaching for it, treat it like the dependency it is, and pair it with a skill when the raw result needs framing.
If you want a second set of eyes on which internal tools are worth exposing, and how to scope a server so it stays safe, that is the kind of thing I work through with teams in MCP server setup and integration. Or book a fifteen-minute call and bring the one tool you wish Claude could reach. We will sketch the server and, more importantly, the description, together.