TL;DR
I spent a weekend building my first MCP server so Claude Code could query our internal service catalog instead of me copy-pasting JSON into the chat. The code took two hours. Getting the agent to actually use it well took the other fourteen — and almost all of that was tool descriptions, output size, and error messages, not TypeScript. Here's the working server and the 5 lessons I'd tell past me.
The Problem
I kept doing the same dumb loop.
Claude Code would be halfway through a change and ask something like "which team owns the billing-events service, and what's its current deploy target?" That data lives in our internal service catalog — a small read-only HTTP API behind our VPN. So I'd tab over, hit the endpoint, pipe it through jq, paste the output back, and watch the agent continue.
Ten times a day. Every day.
The obvious fix is "give the agent a tool." The part I underestimated was that giving an agent a tool is a UX problem, not a plumbing problem. The plumbing is a solved, boring, well-documented thing. The UX — what the tool is named, what it says it does, what it returns, what it says when it fails — is where you actually spend your weekend.
Constraint that made it interesting: the catalog API is chatty. A single service record is ~40 fields, most of which no one has looked at since 2023. Dumping that into an agent's context is how you burn 8k tokens to answer "who owns this."
How I Solved It
Quick orientation for anyone who hasn't touched MCP yet.
MCP (Model Context Protocol) is an open protocol for exposing tools, resources, and prompts to an LLM client. You write a server; the client (Claude Code, in my case) launches it, asks what it can do, and calls into it. The important architectural bit: the server is a separate process, and the transport is usually stdio — the client spawns your process and talks JSON-RPC over stdin/stdout.
flowchart LR
A[Claude Code] -->|spawns process| B[MCP server]
B -->|tools/list| A
A -->|tools/call| B
B -->|HTTPS| C[Internal catalog API]
C -->|JSON| B
B -->|text content| A
That "separate process over stdio" detail has one consequence that bites everyone once: anything you print to stdout is protocol traffic. A stray console.log corrupts the stream and your server dies with a cryptic parse error. Log to stderr. I'll come back to this.
The skeleton
I used the TypeScript SDK (@modelcontextprotocol/sdk 1.x, Node.js 22.x). Here's the whole server minus the API client, and it really is this small:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { fetchService, searchServices } from "./catalog.js";
const server = new McpServer({
name: "service-catalog",
version: "1.0.0",
});
server.registerTool(
"lookup_service",
{
title: "Look up a service",
description:
"Get ownership, deploy target, and on-call info for one service " +
"by its exact catalog name (e.g. 'billing-events'). Use this when " +
"you know the service name. If you only have a partial name or a " +
"team name, use search_services first.",
inputSchema: {
name: z.string().describe("Exact service name, lowercase-hyphenated"),
},
},
async ({ name }) => {
const svc = await fetchService(name);
if (!svc) {
return {
content: [
{
type: "text",
text:
`No service named "${name}". Service names are ` +
`lowercase-hyphenated. Try search_services with a partial name.`,
},
],
isError: true,
};
}
return { content: [{ type: "text", text: summarize(svc) }] };
},
);
await server.connect(new StdioServerTransport());
Then in .mcp.json at the repo root, so it's checked in and my whole team gets it:
{
"mcpServers": {
"service-catalog": {
"command": "node",
"args": ["./tools/catalog-mcp/dist/index.js"],
"env": { "CATALOG_TOKEN": "${CATALOG_TOKEN}" }
}
}
}
claude mcp list to confirm it connected, and that's it. Two hours, most of it spent remembering how tsconfig module resolution works.
The part that actually took the weekend
The first version "worked" in the sense that the tool was callable and returned correct data. It was still useless, and here's the transcript pattern that told me so:
Me: Who owns billing-events?
Claude: (callsget) → 6,200 tokens of JSON
Claude: Thebilling-eventsservice is owned by... let me check theowner_team_reffield... it'st_8813.
Correct! Also completely unhelpful, expensive, and it didn't resolve the team ID to a human name because I never gave it a way to. Everything below came out of fixing that.
Lessons Learned
1. The tool description is the actual product
I initially wrote description: "Look up a service". That's a docstring, not a description.
The description is the only thing the model reads when deciding whether to call your tool. It's not documentation for humans who'll read the source — it's a prompt fragment injected into the agent's decision-making. Mine went from 5 words to 4 lines, and the useful additions were all about routing:
- What input format it expects (
lowercase-hyphenated) - When to use this tool vs. the neighboring one ("if you only have a partial name, use
search_servicesfirst") - What it does not do
Before I added the "use search_services first" hint, the agent would call lookup_service with "Billing Events", get nothing, and give up. After, it self-corrected on the first miss every time. Same code, different sentence.
If you only tune one thing, tune the descriptions. It's the highest leverage-per-character work in the whole server.
2. Cap your output, and shape it for reading
That 6,200-token dump was the real bug. So I stopped returning the API response and started returning a summary:
function summarize(svc: Service): string {
return [
`service: ${svc.name}`,
`owner: ${svc.ownerTeamName} (${svc.ownerSlackChannel})`,
`on-call: ${svc.oncallRotation ?? "none"}`,
`deploy target: ${svc.deployTarget}`,
`tier: ${svc.tier}`,
`repo: ${svc.repoPath}`,
`last deploy: ${svc.lastDeployAt}`,
].join("\n");
}
Seven lines. ~60 tokens instead of 6,200. And note ownerTeamName — the API returns owner_team_ref: "t_8813", so the server does the second lookup and resolves it. Do the join in your server, not in the agent's head. Every field the agent has to chase is another round trip and another chance to guess.
For search_services, which can match many rows, I hard-capped the result at 20 and appended a line saying so:
const shown = hits.slice(0, 20);
const note = hits.length > 20
? `\n\n(showing 20 of ${hits.length} matches — narrow your query)`
: "";
That trailing note matters more than the cap. A silently truncated list reads to the agent as a complete list, and it will confidently tell you a service doesn't exist because it was item 21.
3. Error messages are prompts too
Compare:
❌ Error: 404
✅ No service named "Billing Events". Service names are lowercase-hyphenated.
Try search_services with a partial name.
The first one makes the agent apologize to me. The second one makes it fix itself and try again — usually within the same turn, with no input from me.
Every error path in an MCP server should answer: what went wrong, and what should you do instead? Treat isError: true responses as a recovery instruction, not a status code. This was maybe a 20-line change across the server and it produced the single biggest jump in how often the agent finished a question without me stepping in.
4. Fewer, coarser tools beat a faithful API mirror
My instinct was to mirror the REST API: get_service, list_services, get_team, list_team_services, get_deploy_history. Five tools, one per endpoint. Clean, symmetric, and wrong.
What happened: the agent chained three calls to answer questions I could have answered in one, and it picked the wrong first tool maybe a third of the time because the boundaries between them were fuzzy from the outside.
I collapsed it to two tools — lookup_service and search_services — and folded the team/deploy lookups inside them. Selection accuracy went to basically 100%, because there's exactly one meaningful decision left: "do I know the exact name or not?"
The general shape of the rule:
Design tools around questions a user asks, not around endpoints your API exposes. If two tools are always called together, they're one tool.
I'd rather have 2 tools with a bit of internal branching than 5 tools the model has to disambiguate on every turn.
5. Test the server standalone before wiring it into the agent
For the first few hours I debugged by asking Claude Code questions and squinting at whether the answer looked right. That's a miserable feedback loop — two layers of nondeterminism between you and the bug.
The MCP Inspector fixes it:
npx @modelcontextprotocol/inspector node ./dist/index.js
It gives you a UI that lists your tools and lets you call them with hand-written arguments, showing the raw response. Now it's a normal API debugging session: deterministic input, deterministic output, no model in the loop.
And the stdout thing, since it cost me 40 minutes: a single console.log in your server will break the protocol. stdout is the transport. What you get is a JSON parse error with no obvious connection to the line you added. Wire this up on day one:
const log = (...args: unknown[]) => console.error("[catalog-mcp]", ...args);
Anything that isn't a protocol message goes to stderr. I now add that line before I write anything else.
What's Next
Two things I'm working on.
Resources, not just tools. Right now everything is a tool call. But the service catalog's tier definitions and deploy-target glossary are static reference material — those are a better fit for MCP resources, which the client can attach to context directly rather than round-tripping through a call.
Write access, carefully. The catalog has a PATCH endpoint for updating ownership, and there's an obvious appeal to "agent notices stale ownership and fixes it." There's also an obvious way for that to go badly. If I do it, it goes behind a confirmation prompt and a dry-run mode that returns the diff without applying it — read-only tools are forgiving, write tools are not.
Wrap-up
The summary I'd give myself before starting:
- Descriptions are prompts. Say what the tool does, when to use it, and when to use a different one.
- Cap and shape output. Summarize, resolve references server-side, and say so out loud when you truncate.
-
Errors should teach. "404" is a dead end; "try
search_services" is a recovery path. - Merge tools that are always called together. Model the question, not the endpoint.
- Debug with the Inspector. Take the model out of the loop while you're fixing plumbing.
The protocol itself is genuinely easy — if you can write an Express handler, you can write an MCP server this afternoon. Budget your time for the interface design instead, because that's the part that decides whether the agent uses your tool well or just uses it.
If you're building your own MCP server and hit something weird, drop it in the comments — I'd like to collect the sharp edges. And follow me here on Dev.to if you want the follow-up on MCP resources and safe write access; I'm writing it as I build it.
Versions used: @modelcontextprotocol/sdk 1.x, Node.js 22.x, TypeScript 5.x, Claude Code (2026-08).
Top comments (0)