MCP servers with mcp-go¶
The Model Context Protocol lets an AI assistant call your code. You expose tools; a client discovers them, decides when to call them, and feeds the results back to the model.
Module:
github.com/mark3labs/mcp-go.
srv := mcpserver.NewMCPServer("demo", "1.0.0",
mcpserver.WithToolCapabilities(true),
)
srv.AddTool(searchTool(), s.handleSearch)
What MCP is¶
A JSON-RPC protocol between a client (Claude Desktop, an IDE, an agent) and a server (your process). The server offers three kinds of thing:
| Primitive | Meaning |
|---|---|
| Tools | functions the model may call, with a JSON Schema |
| Resources | readable content the client can fetch |
| Prompts | reusable prompt templates the user can invoke |
Tools are what most servers implement, and what this article covers. The model chooses when to call one, so the description and schema are not documentation — they are the interface the model reasons about.
A tool is a schema¶
func searchTool() mcpgo.Tool {
return mcpgo.NewTool("search",
mcpgo.WithDescription("Search the knowledge base."),
mcpgo.WithString("query", mcpgo.Required(), mcpgo.Description("What to search for")),
mcpgo.WithNumber("limit", mcpgo.Description("Max results, default 10")),
)
}
That produces JSON Schema:
{"properties":{"limit":{"description":"Max results, default 10","type":"number"},
"query":{"description":"What to search for","type":"string"}},
"required":["query"],"type":"object"}
Write the descriptions carefully. They are the only thing telling
the model what a tool does and when to use it. "Search" is useless;
"Search the knowledge base by keyword; returns up to limit matching
documents with their ids" produces far better tool selection. Say what
it returns and when not to use it.
Separate definition from behaviour¶
A convention worth adopting early: schemas in one file, handlers in another.
internal/mcp/
tools_search.go → func searchTool() mcpgo.Tool
handle_search.go → func (s *Server) handleSearch(...)
server.go → registration
Once there are more than a handful of tools, a file mixing both is unreadable — and the schema is the part you re-read most, since it is what the model sees.
Handlers, and the untyped argument map¶
func (s *Server) handleSearch(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
q := argString(req, "query")
if q == "" {
return toolError("query is required"), nil
}
return toolJSON(map[string]any{"query": q, "hits": []string{"a", "b"}}), nil
}
Arguments arrive as map[string]any, so every handler needs
accessors. Write them once:
func argString(req mcpgo.CallToolRequest, key string) string {
args, ok := req.Params.Arguments.(map[string]any)
if !ok {
return ""
}
v, _ := args[key].(string)
return v
}
The comma-ok assertion returns "" for both "absent" and "wrong
type" — so if you need to tell those apart, write a second accessor
returning (string, bool). Numbers arrive as float64, exactly as in
encoding JSON.
The key idiom: errors are data¶
This is the thing to internalise.
A validation failure returns (toolError(msg), nil) — a result marked
as an error, with a nil Go error. The message goes back to the
model, which can read it and try again with a better argument.
Return a non-nil Go error only for a genuine transport or protocol failure. Doing it for a bad argument turns something the model could have fixed into a broken call.
The corollary: write tool error messages for the model. "query is required" is actionable. "invalid input" is not.
func toolError(msg string) *mcpgo.CallToolResult {
return mcpgo.NewToolResultError(msg)
}
func toolJSON(v any) *mcpgo.CallToolResult {
b, err := json.Marshal(v)
if err != nil {
return toolError("encoding result: " + err.Error())
}
return mcpgo.NewToolResultText(string(b))
}
Funnel every result through two or three helpers like these, and the output shape stays consistent across a hundred tools.
Wrapping every tool¶
Registration is the place to add cross-cutting behaviour, using the decorator shape from middleware:
func (s *Server) tracked(name string, h mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
ctx, span := tracer.Start(ctx, "mcp."+name)
defer span.End()
start := time.Now()
res, err := h(ctx, req)
s.hist.Record(ctx, time.Since(start).Seconds())
return res, err
}
}
srv.AddTool(searchTool(), s.tracked("search", s.handleSearch))
One wrapper gives every tool tracing, metrics, logging and an audit
trail. Add a recover in there too — a panic in a handler otherwise
takes the server down.
Transports¶
// stdio: the client launches your binary
mcpserver.NewStdioServer(srv).ServeStdio(ctx)
// HTTP: a long-running service
mcpserver.NewStreamableHTTPServer(srv)
stdio is how a desktop client runs a local server: it spawns your
process and talks over stdin and stdout. Which means nothing else
may write to stdout — a stray fmt.Println corrupts the protocol
stream. Send logs to stderr, and configure slog accordingly.
Streamable HTTP suits a shared, deployed server. It mounts as a handler, so it sits alongside your existing routes.
A --mode flag switching between them lets one binary do both.
Authentication¶
stdio inherits the trust of whoever launched the process. HTTP does not, and an MCP endpoint is an API that executes code on request.
At minimum require a bearer token. For a multi-user deployment, OAuth 2.1 with the discovery endpoints is the specified approach, built on the pieces from OIDC and OAuth.
And design the tools defensively. A model can be talked into calling things — so a tool that runs caller-supplied SQL should parse and restrict it, one that writes should be scoped, and destructive operations should not be exposed at all.
The other direction: calling a model yourself¶
An MCP server exposes tools to a client. Sometimes you are the
client — your service calls a model and lets it use your tools. The
primitives are all covered already: an
HTTP client with
timeouts and retries, a
rate limiter, and
custom JSON marshalling
for the tagged-union content blocks these APIs use. Most providers need
no SDK; net/http is enough.
What is new is the shape of the conversation. A tool-use loop:
for range maxTurns {
resp, err := client.Complete(ctx, messages, tools)
if err != nil {
return err
}
calls := resp.ToolCalls()
if len(calls) == 0 {
return resp.Text(), nil // the model is done
}
messages = append(messages, resp.AsMessage())
for _, call := range calls {
result := dispatch(ctx, call) // run the tool
messages = append(messages, toolResultMessage(call.ID, result))
}
}
The model decides when to stop. Four things to get right:
- Bound the turns. Without
maxTurns, a model that keeps calling tools loops until your budget is gone. - Errors go back as results, exactly as on the server side. A failed tool call is a message the model can react to, not a Go error that aborts the loop.
- Watch the context window. Every result is appended, so a tool returning large output fills the window in three turns. Truncate, paginate, or return ids.
- The context deadline covers the whole loop, not one call. Budget for several round trips.
Prompts are worth keeping out of the code — in files, or a database
table — so they can change without a deploy, and rendering them with
text/template beats
string concatenation.
Designing tools the model can use¶
- Few, broad tools beat many narrow ones. A model choosing among twenty tools does better than among two hundred.
- Return structured JSON, not prose. The model parses it.
- Keep responses small. Everything returned enters the context window; paginate, and return ids the model can follow up on.
- Make them idempotent where possible. A retried call should be safe.
- Say what a tool does not do. Preventing a wrong call is as valuable as enabling a right one.
From Python: the protocol is identical and the official Python SDK is more magical — decorators inferring the schema from type hints. Here you declare the schema explicitly, which is more typing and leaves no doubt about what the model is being shown.
Quick reference¶
| Task | Form |
|---|---|
| a server | mcpserver.NewMCPServer(name, version, opts...) |
| a tool schema | mcpgo.NewTool(name, WithDescription, WithString(...)) |
| required argument | mcpgo.Required() |
| register | srv.AddTool(def, handler) |
| handler | func(ctx, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) |
| read arguments | assert req.Params.Arguments.(map[string]any) |
| a bad argument | return toolError(msg), nil — nil Go error |
| a real failure | a non-nil Go error |
| results | one toolJSON/toolText helper, used everywhere |
| cross-cutting concerns | wrap the handler at registration |
| local client | ServeStdio — nothing on stdout but the protocol |
| deployed | NewStreamableHTTPServer, behind auth |