gtb, the command-line tool that sits in the middle of my Go framework, has fifty-four commands. Until this week, every conversation with an assistant that had gtb attached opened by handing over all fifty-four of them, schemas included, before anyone had typed a word. About 112 KB of JSON, sat in the context of a chat whose first question was probably “what version is this”.
And I’d written a post, back in March, calling that a feature.
The door I’d framed, and what was behind it
I still stand by most of that post. A well-built CLI is already a structured description of a set of capabilities, and the Model Context Protocol is the translator that lets an assistant read it, and go-tool-base put that translator in the framework so each tool got it for free, and all of that still holds.
What I hadn’t clocked is where the bill lands. A tool with eighty commands ships eighty schemas into each conversation, most of them never used, and the tool doesn’t pay for any of that. The model does, in context it can no longer spend on the actual problem, and so does the person sat in front of it, in an assistant that gets a little vaguer with every command you add. I’d built a floodlight, pointed it at the whole yard, and called the glare “discoverability”.
The spike that measured it is on the project wiki, and the numbers aren’t subtle. Ten commands published one-tool-each cost a client 16.7 KB of descriptors, a hundred cost 167 KB, and a thousand cost 1.67 MB.
That’s a novel, delivered with each connection!
The three-tool facade that replaces them cost 691 bytes at every size, because it is the same three tools whether the catalogue has ten operations in it or a thousand, and that one row of the table is the whole argument for the module.
So, go/mcp is public, v0.2.0 is out (v0.1.0 lasted a day), and it carries a lantern instead.
Three tools, however big the catalogue
Let me back up a step, because the fix only makes sense once you see what a “tool” is from the model’s side of the table. When an assistant connects to something over MCP, the first thing it does is ask for the list of tools on offer, and each entry in that list carries a name, a description of what it does, and a schema saying what arguments it takes. That list gets loaded into the conversation, and it stays there for the whole chat.
So one tool per command means the model is reading gtb’s entire manual before you’ve asked it anything, and every message after that is written with the manual still open on the desk.
Now, by default a server built on go/mcp hands over three tools instead: search_tools, get_tool_details and call_tool. The model asks for what it’s after (or browses, with an empty search), gets back a short list of matches with a line each, picks one, asks for that one’s details, and then calls it. Only at that third step does a schema turn up in the conversation, and only the one it’s about to use. The catalogue underneath can be as big as the tool wants to be, and what the model is carrying stays three descriptions long.
The trick is that nothing in the protocol knows this is happening. To the client, call_tool is an ordinary tool that happens to take a name and a bag of arguments. The searching and inspecting is a convention the server and the model agree on between themselves, through nothing more than the tool descriptions, and the protocol is none the wiser. So it works with any client that speaks MCP today, no client-side support needed and nothing to wait for!
Search is bounded too, so it can’t hand the whole catalogue back a page at a time… five results a page by default, twenty at most, a 256-byte query, and page cursors that are encrypted, tied to the caller and expire after ten minutes (starting numbers from the spec, tuned against a made-up catalogue of a thousand operations rather than anything real, and I suspect they’ll move).
The trade, and the floodlight is still in the cupboard
A lantern shows you the next step and hides the rest of the yard, and there’s a cost to that, so let me walk you through it.
Some clients, Claude Desktop among them, will stop and ask you “are you sure?” before running a tool that might do damage. They know which ones might, because a tool can carry a few little labels on it (MCP calls them annotations: this one only reads, this one is destructive, this one talks to the outside world), and the client reads those labels before it decides whether to ask. That’s a good mechanism, and it’s one worth keeping.
Here’s the catch. That client asks about the tool it can see, and with three tools on the table the only one it ever runs is call_tool. So call_tool has to wear the cautious labels (destructive, talks to the outside world, the lot), and every operation behind it gets the same “are you sure?” whether it deserves one or not. The per-command labels your application declared still exist. The model can read them when it asks for a tool’s details, and the server can read them when deciding what to allow. The client’s own prompt, though, can’t, because it’s looking at call_tool and call_tool is all it sees.
If your users lean on that prompt, compact mode has taken something from them.
So the floodlight is still in the cupboard, one option away:
server.New(registry, server.WithMode(server.Direct))
Direct mode is the old shape: every operation published as its own tool, with its own schema and its own labels, and the client’s prompt works exactly as it did before. Same registry underneath, same rules about who can run what, same results coming back. The only thing that changes is how much the model has to carry. You choose it when you build the server, and it is never guessed from how a client behaves, so a host that wants both runs two servers on two endpoints.
(I’d stay compact, but then I would.)
It runs the command the way you would have
The next question, once a model has picked a command, is how it actually gets run, and this is where I kept an old decision instead of replacing it.
go-tool-base had been serving MCP through ophis since its first commit, and ophis ran every call as a separate child process rather than calling the command inside the server. That looked like overhead until I thought about what a Cobra command tree actually is: a big lump of shared, mutable state, with every flag bound to a variable when the tree is built. Two calls to the same command in one process are two callers writing the same variables, and no amount of being careful makes that safe.
So the Cobra binding in go/mcp never calls a command’s RunE in the server’s own process either. Each call starts the bound binary again as a subprocess, with the command path, one --name=value token per flag, a -- separator, then the positional arguments, so an argument that looks like a flag stays an argument and nothing passes through a shell on the way. The command’s own pre-runs, config loading and middleware run as they would from a terminal, which means what the model gets back is what you’d have got.
It’s kept on a short lead, too. One command runs at a time per binding (a second call fails straight away with a retryable busy rather than queueing, because a call that runs later than the caller expected has no way to say so), it gets five minutes, a megabyte of retained output across both streams, and a five-second cleanup budget, and searching and inspecting never take the slot, so the lantern stays lit while a command runs.
A cancelled command isn’t just killed, either. It was started inside a process group (a job object on Windows), so a sleep 600 a script happened to spawn goes with it, and cleanup polls until the group reports no live process.
Ophis got the isolation right first, and I’m grateful for it. What it couldn’t give me was the discovery model, a registry a service could share with a CLI, or the rules about who can run what.
Search is not a gate
Those rules are the last piece, and the one thing from the docs I’d want you to carry around.
When you build a server on go/mcp you hand it a policy, which is your answer to “may this caller run this operation, with these arguments?”. The server asks that question three times: when a caller searches, when a caller asks for a tool’s details, and again when a caller invokes it, with the validated arguments in hand, even if the details were fetched a moment ago. Nothing you learn by asking grants you anything, and neither does being in the catalogue in the first place.
And a name a caller isn’t allowed to run gets the same answer as a name that doesn’t exist, so there’s no probing the catalogue by guessing.
Paranoid, kinda, for a CLI on your own laptop, and mostly it is… right up until the same registry is mounted inside a service and the caller is someone you’ve never met.
Where it mounts
That leaves the three places it lives, and for a Cobra CLI it’s one line:
root.AddCommand(mcpcli.Command(mcpcli.WithBinding(mcpcobra.WithExposure(isExposed))))
That gives your tool an mcp command. my-tool mcp start serves every exposed command over stdio, which is how an editor talks to it; mcp stream serves the same thing over HTTP; mcp tools exports the catalogue as JSON so you can see what a model would; and mcp claude, mcp cursor and mcp vscode write the editor’s config entry for you (and remove it, and list it) without touching anything else in that file. The spellings are ophis’s on purpose, so a script or an editor entry written for it keeps working.
For an HTTP service the shape is different but the pieces are the same. You register the operations you want to expose explicitly, and you get back one http.Handler to mount beside your own routes, behind the middleware you already run and under the lifecycle you already have. Your body limit applies to it, a request from a website you didn’t list is refused before the protocol sees it, a client disconnecting mid-call cancels the operation, and shutdown waits for what’s in flight.
And for a gRPC service, which is what v0.2.0 added this morning, you bind each unary method as an operation with mcpgrpc.Unary, naming it by its generated full-method constant and handing over the generated server method itself. A call runs in-process rather than dialling the service’s own listener, but it runs through the service’s own interceptor chain, with the identity your HTTP middleware verified established on the context the way the gRPC auth interceptor would have, the same authorisation predicate applied, the call’s deadline and trace carried in. Arguments and results are the request and response messages in protojson, and the schemas a model sees are derived from the message descriptors, so there’s no JSON Schema to write by hand. Streaming methods are refused at registration. Unary only, for now.
The reason one registry can serve all three is that the root package imports none of it: no protocol SDK, no Cobra, no HTTP, no gRPC, no lifecycle framework, with a test that keeps it so. A CLI and a service share the operation model and nothing else.
go-tool-base consumes it on main, so a tool built on its next release serves MCP through go/mcp, in compact mode unless its author says otherwise. The mcp feature became a link while I was at it, meaning a tool that disables it ships without the module and without the MCP SDK in its binary (a separate post, that one).
Where it stands
Pre-1.0, and moving… v0.1.0 went out last night with the operation model, the SDK adapter over stdio and Streamable HTTP in both modes, the Cobra binding and the mcp command tree, and v0.2.0 followed it this morning with the gRPC binding. The mounted-service shape is proven by the module’s own tests.
Native macOS and Windows subprocess acceptance is wired up and has not yet run, and the same goes for the browser side of the Apps shell; the development reference says what that means instead of leaving you to guess. The docs are at mcp.go.phpboyscout.uk, the design is spec 0001 on the wiki, and it installs the usual way:
go get gitlab.com/phpboyscout/go/mcp
Credit where it’s due, and it isn’t all mine: a different agent built the bulk of the module over a week from that spec while I was busy elsewhere, ran out of quota, left a handover on the wiki, and the session I was in picked it up cold and finished it. How that went, and what the handover got wrong, deserves its own telling and I’ll get to it.
For six months I thought showing an assistant everything I had was the generous thing to do. Fifty-four schemas and 112 KB of JSON later, gtb carries a lantern, and the assistant on the other end gets to spend its context on the actual problem for a change… and given the sort of problems I hand it, it’s going to need every bit of that.





