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 (1)
"Giving an agent a tool is a UX problem, not a plumbing problem" — that's the sentence I wish someone had handed me at the start. Thanks for writing this one up honestly, especially the fourteen-hours-versus-two ratio.
We run an MCP server in production with 122 tools, and reading your five lessons was uncomfortable in the good way: we've collected a scar for nearly every one. A few notes from our side, in case any of them are useful for the follow-up you mentioned.
On lesson 4 (fewer, coarser tools) — we're probably your counterexample.
122 tools is a lot, and your instinct is right. What we found, for what it's worth, is that raw count hurt us less than overlap did. Twelve tools that each answer a genuinely different question coexist fine. Two tools whose descriptions could both plausibly answer "what do we know about X" cost a wrong pick surprisingly often — which is exactly the get_service / list_services fuzziness you describe.
The rough test we ended up using on ourselves: can we write the one sentence that says when not to use this tool? When ours came out as "use this unless you'd rather use the other one," it was usually one tool wearing two hats. Does that match what you saw when you collapsed five into two — was it the count that hurt, or the fuzzy boundary?
On lesson 1 (descriptions are prompts).
Completely agree, and it had a consequence we didn't see coming: if a description is a prompt, it's code — and it rots like code. Ours drifted away from the handler's actual behaviour more than once, quietly.
We ended up snapshotting every tool spec (name, description, schema) to a file and failing CI when a description changes without the snapshot being updated. It felt bureaucratic when we added it. It costs a few seconds per PR and has caught a couple of "the description still promises a thing the handler stopped doing" cases.
On lesson 2 (cap output, and say so) — we hit a sneakier variant.
Your trailing showing 20 of 137 note is the part I'd underline hardest too. We ran into a version of the same problem one level down, and it took us a while to see it.
Our session briefing truncates each stored lesson to about 100 characters. Not a list truncation — a field truncation, inside every row. When we finally measured what the agent actually receives, we found a lesson whose decisive fact (a server address) sat at character 323. The lesson was displayed. The agent read the first 100 characters, learned nothing actionable, and made exactly the mistake that lesson existed to prevent.
So the announce-the-truncation habit had saved us on lists and quietly missed us one level down. What we added afterwards was a writing convention — number, address, command first, backstory last — plus a small check at write time that names the position: "the decisive fact is at character 323, the briefing ends at 100." Naming the number is what turned it from a nag into a ten-second fix.
Did your summarize() ever end up cutting inside a field, or did the seven-line shape make that a non-issue by construction? I suspect the fixed shape saves you here and we only hit it because ours is free text.
On lesson 3 (errors are prompts).
This one we agree with so hard it hurts, because our worst case wasn't an error at all — which is why it survived for weeks. A free-tier gate rendered this:
A header with a count, then zero rows. The code even carried a comment a few hundred lines above promising the opposite — "never an access wall: the #1 result is always returned" — while the branch underneath said visibleCount = gateActive ? 0 : 8. Same promise in two places, only one of them maintained. To an agent (and to a person) that doesn't read as a bill; it reads as a broken tool.
And a fresh one from this week that's your lesson 3 with a small twist. One of our tools answered: "none of the stored terms appear in this text." Perfectly truthful — and it cost someone half a session, because it never mentioned which text it had searched. It was looking at a card's headline; the terms lived in the card's body. The person went hunting through the glossary instead. The fix was four words: "in the headline and body of this object."
Which left me wondering where the line is: is naming the search scope in a "not found" message worth the extra tokens on every miss, or does that get chatty fast? You've clearly thought harder about the token budget than we have.
On lesson 5 (Inspector before agent).
Yes — and your console.log-breaks-stdio story belongs to a family we keep meeting: failures that are silent by construction. The habit that's helped us most there is asking, for every guard or cap we add, whether it can actually say no. We ran that audit on our own test gate once and found it had silently checked nothing in 50 of its last 64 runs. It had been green the whole time. A skipped check and a passing check look identical unless you make the check prove it ran.
On your "what's next": the dry-run-returns-the-diff shape for write access sounds right to us. One thing that surprised us is that read-only tools can have a write-shaped side effect anyway — every read of ours bumped a recall counter, and for a stretch our own usage metrics were inflated by our own background pings. Might be worth deciding early whether a given call is an observation or an event, because after the fact they're hard to separate.
Really enjoyed this. If you do write the resources follow-up, I'd read it.