I built an MCP server with twelve tools. Here is what the tutorials leave out.
By Andrey ChmerevI build Kekoso, a Mac dictation app that ships an MCP server so agents can transcribe audio locally. Everything below is from that codebase — the decisions, and the mistakes that produced them. Written 5 September 2026.

Writing an MCP server is a weekend. Implement tools/list, implement tools/call,
speak JSON-RPC, and an agent starts calling your code. Every tutorial on how to
build an MCP server gets you that far, and they are right that it is easy.
Then you ship it, and the interesting problems begin — none of which the Model Context Protocol specifies, because they are not protocol problems. Ours has twelve tools that hand a coding agent local transcription, and each of the sections below is something we got wrong first.
The tool list guards nothing
The obvious way to make a tool conditional is to leave it out of tools/list.
The agent never sees it, so the agent never calls it. This is wrong, and it is
wrong in a way that already has a CVE number.
CVE-2026-46519
is exactly this bug in mcp-server-kubernetes. It documented three environment
variables as access controls — including one that promised read-only operation —
and enforced all of them in the handler that builds the tool list. The handler
that executes calls had no such check. Any client that knew a tool name could
call kubectl_delete in read-only mode, and the advisory names the pattern
precisely: presentation-layer filtering without execution-layer enforcement.
So our gates sit on execution and nothing else. Four of our twelve tools read
dictation history and call recordings, and they are behind a switch that ships
off. They still appear in tools/list when that switch is off — always, every
time. The check lives at the top of the handler that runs them, before argument
parsing and before the history file is opened at all. An agent that knows the
name gets a refusal, not a silence.
The rule generalises past permissions: your tool list is a description of your server, not a description of what your server will do. Anything you enforce by omission is not enforced.
Make the destructive path unreachable, not forbidden
Two of our tools delete things — a vocabulary entry, an installed model. Both file a request that a human confirms in the app; neither deletes anything itself.
The first version did this the ordinary way: the handler held references to the
vocabulary store and the model store, checked whether a confirmation existed,
and then called remove. It worked. It was also one forgotten branch away from
being the Kubernetes bug in a different costume — the compiler would have
happily accepted a direct remove call slipped in during a later edit, and the
only thing standing between a user’s data and that edit would be my memory.
The version we shipped removes the possibility instead. The destructive handler
holds no reference to either store. It cannot delete anything, in the same sense
that it cannot open a network socket: the call does not exist in its scope. The
one place where remove can be reached is a private method on the confirmation
queue, called from a single site that is only reachable after a human has
pressed confirm.
This is worth the awkwardness it costs. A rule enforced by an if is a rule
someone can forget; a rule enforced by what a type can reach is a rule the
compiler keeps for you.
The user ID on the other end is not an identity
Most mcp server security advice stops at “do not expose it to the internet”,
and a local socket looks like it has already satisfied that. It has not. Our
server listens on a Unix socket in a directory with 0700 permissions, which
felt like enough until I thought about who else is on the machine. A
developer running Claude Code typically has several MCP servers going — written
by strangers, doing arbitrary things — and every one of them runs as the same
user, with the same access to that directory. File permissions keep out other
accounts. They do not keep out the neighbours.
So the socket checks two things when it accepts a connection. getpeereid for
the user ID, which is cheap and rejects almost everything, and the peer’s code
signature against our development team, which is the only check that
distinguishes our own helper from another MCP server with identical
credentials.
The detail that matters: verify the audit token of the accepted connection,
not its PID. PIDs are reused, and a check keyed on a PID has a window between
asking who that process is and receiving an answer about somebody else. The
audit token identifies the process that was on the other end at accept time
and cannot come to mean a different one.
This is also why the server speaks raw BSD sockets rather than
Network.framework, which is otherwise the better API on Apple platforms:
NWListener does not hand you the file descriptor of an accepted connection,
and without a descriptor there is no peer credential and no audit token to
check. The identity requirement chose the networking layer, not the other way
round.
The client truncates you, and the agent never learns why
A one-hour transcript is not a large object by any normal standard. It is far past what an MCP client will pass through.
Claude Code caps MCP tool results at 25,000 tokens by default and persists anything larger to disk, handing the agent a file reference in place of the content. The cut happens on the client side, after your server has already returned a correct response, so nothing in your code sees it and nothing in the agent’s context explains it. For a transcription tool this is not an edge case — it is what happens on almost every real recording.
Two things fixed it. A tool can raise its own ceiling by annotating its
tools/list entry with _meta["anthropic/maxResultSizeChars"], up to 500,000
characters, which covers the overwhelming majority of transcripts outright.
Past that, we truncate ourselves: a leading slice, an explicit marker saying
text was cut, and the path to the full transcript the app has already written to
disk. The agent ends up with either everything, or a clear statement of what is
missing and where to get it. What it never gets is a quiet ellipsis.
We rejected pagination for this, and the reason is worth stating: a cursor costs N round trips to deliver what one file path delivers in zero, and an agent that has extracted the two facts it needed will not ask for page four anyway.
A tool description is an interface, and so is the refusal
I wrote the first tool descriptions as docstrings — what the tool does, what the arguments mean. Then I watched an agent hit a disabled tool, read the failure as a malfunction, and start trying neighbouring tools to get at the same data by another route. It was not being adversarial. It was doing what a competent assistant does with a broken tool: routing around it.
Refusals are now written for that reader. Our history tools, when the switch is off, return a sentence naming the exact setting, saying who can change it, and adding that there is no other way in. The last clause is the one that matters — it converts an obstacle into a fact, and the agent stops looking.
The same thinking changed a schema. Our transcription tools take a language code, and the schema used to enumerate the accepted values. That was fine at two languages and impossible at ninety-nine, and worse, the enumeration lived in a package built separately from the engine, so any list there was a copy destined to fall behind. The schema now promises a shape — ISO 639-1 — and the app treats an unrecognised code as auto-detect. The agent’s mistake degrades instead of failing.
Two ways to ship a server that is dead on arrival
Both of these bit us, both are invisible on the machine that built the code, and both produce the same symptom: the client reports a server that will not start, with no useful error.
The path you wrote into the config will rot. Installing an MCP server means
writing an absolute path to your binary into a client’s config file. Then the
user drags the app from Downloads to Applications, and that path points at
nothing. Our app now repairs its own entry at launch — but only its own entry,
only when the record is already ours, and never by creating one that a person
did not ask for. Somebody else’s kekoso entry is not ours to fix.
A helper with no bundle needs static linking. Our server binary shares a Swift package with the app. Xcode builds a local package product as a dynamic framework by default, which the app embeds happily — and which a command-line tool cannot embed at all, so the linker writes the only path that works on the build machine: an absolute one, into DerivedData. It runs perfectly until it leaves that Mac, then dies in the dynamic linker before its first line. Marking the product static fixes it. The test that catches it is copying the built app somewhere else on disk and running the helper from there.
There is a third one in the same family, less dramatic: blocking reads need
their own threads. accept and read are not suspension points, and a socket
loop parked on a Swift concurrency executor will consume that executor’s threads
and eventually starve everything else. Blocking work pays with its own thread,
not with the shared pool’s.
What this adds up to
The protocol is the easy half. What took the time was the part where you are writing for a caller that is neither a human nor a normal program: something that reads your descriptions as instructions, treats your failures as puzzles, and will call a tool it was never shown.
If you are building an MCP server, the sharpest question to ask about every
guard you write is whether it survives a client that skips tools/list
entirely. Ours does now. It did not at first.
Our own server is described in full on the MCP transcription server page, including the tool list and the config. If you are weighing a local tool against a hosted one for agent work, we did that arithmetic separately in local model versus per-minute API, and the other half of talking to an agent — speaking to it rather than typing — is in voice coding.
Questions people ask
How do I build an MCP server?
The protocol part is small: implement tools/list and tools/call, speak JSON-RPC over stdio or a socket, and any SDK gets you there in an afternoon. What takes the remaining time is everything the protocol does not specify — where permission checks belong, how you identify who connected, what happens when your output is longer than the client accepts, and how a refusal reads to an agent that will otherwise look for a way around it.
Is the tools/list response a security boundary?
No, and treating it as one is a published vulnerability class. CVE-2026-46519 in mcp-server-kubernetes filtered destructive tools out of the tool list but left them callable through tools/call; the advisory calls that presentation-layer filtering without execution-layer enforcement. Any client that knows a tool name can invoke it without ever reading your list.
How does an MCP server know which process connected to it?
Not from the user ID alone. Every other MCP server on that machine runs as the same user, so getpeereid tells you almost nothing. On macOS you can check the code signature of the peer using the connection's audit token — not its PID, which can be reused between the check and the answer.
What happens if an MCP tool returns too much text?
The client cuts it. Claude Code caps MCP tool results at 25,000 tokens by default and persists anything larger to disk as a file reference. A server can raise that ceiling per tool with the _meta annotation anthropic/maxResultSizeChars, up to 500,000 characters — and past even that, truncate honestly yourself rather than let it happen where the agent cannot see it.
Should a local MCP server use stdio or a socket?
Stdio is simpler and is what most clients expect. A socket is worth it when the tools have to reach a running GUI app rather than a standalone process — which is our case, since transcription needs the models the app already has in memory. It also makes peer verification possible, because you have a file descriptor to inspect.
How should an MCP tool describe a permission it does not have?
Say which setting is off, who can turn it on, and that there is no other route in. An agent reads a bare failure as something to work around and will try neighbouring tools. A refusal that names the switch ends the attempt instead of redirecting it.