I have been writing Go MCPMCPModel Context Protocol -- a standard for AI clients to call external tools via JSON-RPC
servers for a few months now. The pattern is always the same: define a struct, add jsonschema tags, call mcp.AddTool. It works. It is clean. And every time I do it, I feel like I am writing the same tool twice, because the CLI version already exists. The scaffolding around the model, as I wrote in
the prompt engineering skeleton, is where the real craft lives. This is that idea applied to tool definitions. Anthropic+1
2 sources
1
Model Context ProtocolAnthropic
2
urfave/cliGitHub
The CLI has flags, descriptions, help text. The MCP server has input schemas, tool descriptions, and handlers. They describe the same operations. They parse the same arguments. They run the same logic. But they live in separate files, with separate definitions, and they drift.
So I built a small project to ask one question: if I already have an urfave/cliurfave/cliA Go library for building command-line applications with flags, subcommands, and help text v3 command tree, can I serve it as an MCP tool catalog without duplicating anything? GitHub 1 source 2 urfave/cliGitHub
The answer is yes, but the path there taught me more than I expected. I wrote earlier about the harness gap between open-weight models and the tooling around them. This is the same question from the other direction: not whether the harness is good enough, but whether the tools we already have can serve twice.
The Setup #
The project is go-mcp-hello-urfave. Five demo tools, each picking at a different edge case: greet has a required string flag, echo has an integer with a default, fortune takes zero arguments, slow_op tests context cancellation, and boom exercises the error path. Not a product. A probe.
The structure is three internal packages. internal/cli holds the command tree and handler functions. internal/bridge converts urfave/cli flag metadata into JSON SchemaJSON SchemaA vocabulary for annotating and validating JSON documents, used by MCP to describe tool input parameters
. internal/server wires everything into an MCP server and runs it over stdio. Internet Engineering Task Force
1 source
3
JSON SchemaInternet Engineering Task Force
The key piece is a package-level variable called entries. It is a slice of CommandWithHandler structs, each pairing a *cli.Command with its handler function. Both the CLI dispatcher and the MCP server read from this same slice. One source of truth.
The Refactor I Did Not Expect #
My first instinct was to keep the handler signature as func(ctx context.Context, cmd *cli.Command) (string, error). The CLI passes the command, the handler reads flags from it, the MCP bridge populates a fresh *cli.Command from JSON-RPC arguments. Same type, same interface.
It does not work. urfave/cli v3’s cmd.Run couples flag parsing and action invocation into a single call. You cannot get the parsed flags without running the action. And when the action runs, it writes to stdout, which is reserved for the MCP JSON-RPC stream. I tried nil-ing the action before calling Run to get flag binding without execution. The flags did not bind. urfave/cli v3 skips parsing when there is no action to call.
The solution was to change the handler signature. Instead of taking *cli.Command, handlers take an ArgMap, which is a map[string]any. The CLI side parses flags and builds the map. The MCP side unmarshals JSON-RPC arguments into the same map shape. One handler, two entry points, and neither knows about the other.
type ArgMap map[string]any
func Greet(args ArgMap) (string, error) {
name := StringArg(args, "name")
msg := "Hello, " + name
if BoolArg(args, "shout") {
msg = msg + "!!!"
}
return msg, nil
}
The CLI side binds handlers via BindCLIHandlers, which sets each command’s Action to a closure that calls cmdToArgs(cmd) and passes the result to the handler. The MCP side does the inverse: unmarshalArgs takes the raw JSON-RPC bytes and produces the same ArgMap.
This refactor was the real work. Everything else followed from it.
The Bridge Is One Function #
Converting urfave/cli flags to MCP input schema turned out to be simpler than I feared. The SDK’s mcp.Tool.InputSchema field is typed as any, which means it accepts any value that JSON-marshals into valid schema. No dedicated struct, no type constraint, no ceremony. Internet Engineering Task Force
1 source
3
JSON SchemaInternet Engineering Task Force
The bridge is a single function:
func SchemaFromFlags(flags []cli.Flag) map[string]any {
props := map[string]any{}
required := []string{}
for _, f := range flags {
name, prop, isReq, ok := flagToProperty(f)
if !ok { continue }
props[name] = prop
if isReq { required = append(required, name) }
}
schema := map[string]any{"type": "object", "properties": props}
if len(required) > 0 { schema["required"] = required }
return schema
}
Each flag type maps to a JSON Schema property. StringFlag becomes {"type": "string"}, IntFlag becomes {"type": "integer"}, BoolFlag becomes {"type": "boolean"}. No reflection, no code generation, no schema package dependency. Just a type switch and map literals.
There is one gap: urfave/cli v3 has no Choices field on StringFlag. Enum validation is done via a Validator callback, which the bridge does not reflect into. The JSON Schema enum field stays empty. The handler’s default case still rejects unknown values at runtime, so the behavior is correct, but the schema does not advertise the constraint. For a hello-world PoC this is fine. For production, you would want to close that gap.
The Bug That Took an Afternoon #
The MCP MCP InspectorMCP InspectorAn official developer tool for testing and debugging MCP servers, with a browser-based UI has a Connect button. You point it at your binary, click Connect, and it performs the handshake. I clicked Connect. The initialize response came back correctly. The server info was right, the capabilities were right, the protocol version was right. And the status badge stayed Disconnected. Anthropic+1 2 sources 1 Model Context ProtocolAnthropic 4 MCP InspectorGitHub
I spent an embarrassing amount of time on this. The server worked perfectly when tested with a Python stdio client. The in-memory transport tests passed. The handshake response was correct. Everything was fine, except the Inspector refused to transition to Connected.
The problem was stderr. The official Go SDK logs session lifecycle events at LevelInfo: server connecting, session connected, session initialized. These are informational messages, the kind you want in production. The Inspector surfaces stderr as Server Notifications in its UI. And this stream of notifications prevented the Inspector’s connected state machine from completing.
One line fixed it: slog.LevelError instead of slog.LevelInfo. The Inspector connected immediately, listed all five tools, and I could call them from the browser. The lesson is narrow but sharp: when a tool surfaces your logs as protocol events, your logs become protocol. Silence is a feature.
What Works and What Does Not #
The upside is real. I have one command tree, one set of handlers, one source of truth. When I test greet --name Ada from the shell and it returns Hello, Ada, the same code runs when an MCP client calls greet with {"name": "Ada"}. The schema comes from flag metadata. Descriptions come from urfave/cli usage strings. I never wrote a separate tool definition.
The downside is also real. The ArgMap refactor is mandatory, not optional. You cannot avoid it by being clever with *cli.Command. The stdout capture via os.Pipe works for the in-memory transport and for stdio, but it is fragile. A handler that writes directly to os.Stdout instead of using println would bypass the pipe. The robust fix is an io.Writer parameter on handlers, but that changes the signature again, and for hello-world it was not worth it.
And the enum gap. urfave/cli v3 validates enums through callbacks, not metadata. Without reflecting into the callback, the JSON Schema does not carry the constraint. The handler still rejects invalid values, but the client does not know the options until it tries.
Where This Goes Next #
This is a PoC, not a framework. I am not going to package it as a reusable library. The value was in finding the seams: where urfave/cli’s design assumptions clash with MCP’s transport assumptions, and what it costs to bridge them. In
the right model for each task, I argued that the interesting question is not the biggest system but the smallest one that completes the job reliably. This is the same instinct applied to tooling: what is the smallest bridge that makes existing code serve a new transport? The ArgMap pattern, the single-function schema bridge, and the stderr silence lesson are the takeaways. The rest is specific to five demo tools in a private repo.
If you already build Go CLIs with urfave/cli v3 and want to expose them as MCP tools, the approach works. Start with the handler refactor, add the bridge function, wire the server. It is maybe 150 lines of code beyond your existing CLI. The repo has the full implementation, tests, and a Makefile target that launches the Inspector so you can poke at it in your browser.