Build Your First MCP Server — Give Claude Your Own Tools
Installing other people's MCP servers gets you 80% there. The last 20% — connecting Claude to your database, your API, your internal tooling — means writing one yourself. It's smaller than you think.
In the plugins post every recommendation was someone else's MCP server — Notion, Supabase, Playwright. They cover the common cases. But the moment you want Claude to touch your internal API, your staging database, your deploy script, there's no plugin in a marketplace for that.
So you write one. And the first time you do, the surprise is how little there is to it. An MCP server is just a program that exposes functions Claude can call. No framework, no magic. A tool is a name, a description, an input schema, and a function. Here's the whole thing, start to finish.
What MCP actually is
MCP — Model Context Protocol — is a standard way for an AI client (Claude Code, Claude Desktop, others) to discover and call tools a server provides. The split is clean:
- The client (Claude) decides when to call a tool and with what arguments.
- The server (yours) declares what tools exist and what they do when called.
That's the whole contract. Your server announces "I have a tool called get_order that takes an orderId and returns order details," and Claude calls it whenever an order comes up in conversation. You never write prompt-parsing logic. Claude handles the language; your server handles the work.
Setup
We'll build a tiny server in TypeScript using the official SDK. One that exposes a convert_pace tool — given a target finish time and distance, it returns the per-kilometer pace. (A toy, but it stands in for any "call my logic" tool.)
mkdir pace-mcp && cd pace-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsxSet "type": "module" in package.json so ESM imports work.
The whole server
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: "pace-mcp",
version: "1.0.0",
});
server.registerTool(
"convert_pace",
{
title: "Convert finish time to pace",
description:
"Given a target finish time and a race distance, return the " +
"required pace per kilometer. Use when a runner asks what pace " +
"they need to hit a goal time (e.g. 'sub-4 marathon pace').",
inputSchema: {
distanceKm: z.number().positive().describe("Race distance in km, e.g. 42.195"),
finishMinutes: z.number().positive().describe("Target finish time in minutes, e.g. 240"),
},
},
async ({ distanceKm, finishMinutes }) => {
const secPerKm = (finishMinutes * 60) / distanceKm;
const min = Math.floor(secPerKm / 60);
const sec = Math.round(secPerKm % 60).toString().padStart(2, "0");
return {
content: [{ type: "text", text: `${min}:${sec} /km` }],
};
},
);
const transport = new StdioServerTransport();
await server.connect(transport);That's the entire server. Read it top to bottom:
- Create a server with a name and version.
- Register a tool — a name, a human-readable title, a
description(this is the part Claude reads to decide when to call it), and aninputSchemawritten in Zod. - The handler receives validated, typed arguments and returns
content— usually text. Zod has already guaranteeddistanceKmandfinishMinutesare positive numbers before your code runs. - Connect over stdio — Claude Code launches your server as a subprocess and talks to it over stdin/stdout.
No HTTP server, no routes, no boilerplate. The Zod schema does double duty: it validates input and tells Claude the shape of the arguments.
Wiring it into Claude Code
Claude Code reads MCP server config from .mcp.json in your project root (or your global config). Point it at the file:
{
"mcpServers": {
"pace": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/pace-mcp/server.ts"]
}
}
}Restart Claude Code, and check it registered:
/mcpYou should see pace listed with one tool. Now ask, in plain language:
What pace do I need for a sub-4:00 marathon?Claude recognizes the distance (42.195 km) and time (240 min), calls convert_pace, and answers 5:40 /km. You wrote a function; Claude turned a sentence into a call to it.
Three things that separate a real server from a demo
The toy above works. Shipping one people rely on takes three more habits.
1. The description is the API
Claude only knows your tool through its description and parameter descriptions. A vague description means Claude calls the tool at the wrong time, or not at all.
// WEAK — Claude won't know when to reach for this
description: "Gets order data."
// STRONG — tells Claude exactly when and why
description:
"Look up a customer order by its ID. Returns status, line items, " +
"and shipping info. Use when the user references a specific order " +
"number or asks about the status of a purchase."Treat tool descriptions like prompts, because they are. When a tool misfires, the fix is almost always in the description, not the code.
2. Validate everything, return errors as data
Your server is a system boundary — Claude's arguments are external input, even though they come from a model you trust. Zod handles shape. But also handle the failures your logic can hit, and return them as readable text rather than throwing:
async ({ orderId }) => {
const order = await db.orders.find(orderId);
if (!order) {
return {
content: [{ type: "text", text: `No order found with ID ${orderId}.` }],
isError: true,
};
}
return { content: [{ type: "text", text: JSON.stringify(order) }] };
}isError: true tells Claude the call failed so it can adjust — maybe ask the user to recheck the ID — instead of treating the error string as a valid answer.
3. Scope it down, hard
This is the security one, and it matters most. An MCP server runs with your credentials and your access. A tool called run_sql that takes an arbitrary query string is a tool that can drop your tables the moment Claude misreads a request.
The same logic as installing other people's servers, pointed inward: every tool you expose is capability you're handing to the model. Hand over exactly what the job needs, and nothing more.
When to actually write one
Not every integration needs a custom server. Reach for one when:
- You have internal tooling with no public plugin — your own API, a private DB, a deploy or migration script.
- You're repeating the same multi-step task by hand — wrap the sequence in one tool and let Claude invoke it.
- You want Claude to read live state — current ticket status, today's metrics, the contents of a staging environment.
If a marketplace plugin already does the job, install that instead — less to maintain. The custom server is for the 20% no one else has built, because it only exists inside your company.
Closing — the shape of the skill
The first MCP server feels like a milestone and turns out to be an afternoon. The protocol is small on purpose. Once the shape clicks — name, description, schema, function — you stop thinking about MCP at all and start thinking about which slices of your own systems are worth handing to the model.
That's the real skill, and it isn't a coding skill: deciding what Claude should be allowed to touch, and describing each tool clearly enough that it reaches for the right one. The code is the easy 20%. The judgment is the rest.
Next post — turning a handful of these tools into a plugin you can install in one line.
Related writing
How to Land Your First Dev Job — It's Risk Reduction, Not an Exam
Landing a junior job isn't an exam that proves your knowledge. It's about reducing the employer's risk. Portfolio, résumé, applications, coding tests, and interviews step by step — plus what AI changed, in an honest field guide.
A Career Path for People Just Starting as Developers — The 2026 Version
Does it still make sense to become a developer when AI writes the code? Yes — but the path changed. An honest roadmap of what to learn and in what order, how to choose a first job, and what to avoid.