We have shipped two MCP servers now — one for Wharfy, so an agent can check whether a port is free before starting a dev server, and one for Papyro, so an agent can read and write boards and notes.
Getting the protocol working took an afternoon. Getting an agent to use it well took considerably longer, and none of that time was spent on the protocol.
The protocol is not the interesting part
The Model Context Protocol is JSON-RPC over stdio or HTTP. A server advertises tools; the client calls them. The official SDKs make a working server about thirty lines. There is very little to get wrong.
Everything that matters happens one level up, in the design of what you expose. The model does not read your source. It reads your tool names, your parameter descriptions, and whatever came back last time. That is the entire interface, and it is a prompt.
Fewer tools, doing more
Our first Wharfy server had eight tools: list_ports, get_port, find_by_project, find_by_agent, kill_port, kill_by_pid, watch_port, unwatch_port.
It was worse than the three we replaced them with.
With eight tools the model spent turns choosing. It would call list_ports, then get_port for detail it already had, then find_by_project to re-derive a grouping that was in the first response. Each call is a round trip, a chunk of context, and a chance to pick wrong.
list_ports with optional filters covers the first four. One tool, one call, and the filter parameters document what filtering is even possible. The rule we arrived at: a tool should map to something the user would ask for, not to a function in your codebase. Nobody asks to "find by agent". They ask what Claude Code left running.
Return shapes are prompts
This is the part we got most wrong at first.
Our early list_ports returned exactly what our internal model held: PID, port, command, cwd, parent PID, socket state, a ProcessKind enum. Complete, and nearly useless. The model saw "kind": 2 and had no idea what it meant, so it either ignored the field or guessed.
Now the same tool returns:
{
"port": 3000,
"process": "node",
"project": "codebiy-main-website",
"startedBy": "Claude Code",
"exposedToLan": false,
"canKillSafely": true
}
canKillSafely is not a field in our data model. It is a judgement — is this a dev server or is it Postgres with your data in it — computed server-side because the server has the information and the model does not. Before it existed, agents happily suggested killing databases.
The general principle: return what the caller needs to decide, not what you happen to store. Every enum you pass through raw is a decoding task you have outsourced to a language model, and it will sometimes get it wrong.
Errors are answers too
An MCP error terminates the model's line of reasoning. It generally responds by apologising to the user, which helps nobody.
Failures that are part of normal operation should be successful responses describing a failure:
{ "killed": false, "reason": "Port 5432 is PostgreSQL, not a dev server. Kill it with `wharfy kill 5432 --force` if you meant to." }
The agent can act on that. It cannot act on Error: refused. We reserve real protocol errors for genuine faults — the daemon is gone, the arguments do not parse — and everything else comes back as data.
Descriptions carry the constraints
Parameter descriptions are the only place to put a rule the model must follow, because they are the only part of your server it reads.
Compare port: number with:
port— the port to release. Development ports only (3000-9999). Uselist_portsfirst to confirm what is holding it; killing a port you have not inspected is how databases die.
The second one changes behaviour. Not perfectly — it is a prompt, not a type system — which is why canKillSafely exists server-side as well. Belt and braces: describe the constraint, then enforce it.
Statelessness, and where it breaks
Every tool call should stand alone. A model that has to call open_session, then list, then close_session will forget the third step, and by the next turn it has forgotten the first.
Papyro's server broke this rule once, with a select_board tool that set the board for subsequent calls. It worked in testing and failed constantly in practice, because a model that reads a card in one turn and writes in the next may have compacted the context between them. Board IDs are now parameters on every call. Slightly more verbose, entirely reliable.
What we would tell ourselves
Start with three tools. Return sentences, not enums. Put the rules in the descriptions and the enforcement in the server. Then watch a real agent use it — the transcript will tell you within five turns which tool it does not understand, and that is a better signal than any amount of design up front.