Skip to content
CP
Writing
6 min read#code

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.

ShareXThreads

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 tsx

Set "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:

  1. Create a server with a name and version.
  2. Register a tool — a name, a human-readable title, a description (this is the part Claude reads to decide when to call it), and an inputSchema written in Zod.
  3. The handler receives validated, typed arguments and returns content — usually text. Zod has already guaranteed distanceKm and finishMinutes are positive numbers before your code runs.
  4. 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:

/mcp

You 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