3.3. MCP
In one glance
- You will: Serve the agent’s reads over MCP, list them from the wire, watch a write tool be refused, and then try to add one on purpose.
- You need: The tool tests passing, a second terminal, and
curlandjqon your path. No model is required. - Time: about 40 minutes, hands-on.
Why the six reads move into their own process
The Model Context Protocol (MCP) is a JSON-RPC protocol that separates the process owning a tool from the agent calling it. A capability can then be discovered and invoked across a process boundary. Without that separation, every agent that needs the same read links its own copy: four copies of one query — three out of date, one with a subtly different definition of “open” — and four processes each holding the database access the query needs. One owning process leaves one implementation, one credential, and one place to put a policy. Here that capability is reading one incident database three teams share.
This page serves those six reads over MCP, lists them from the wire, and tries to defeat the read-only surface.
Start the server in one terminal:
cd agents/go
mise run mcp:httptime=2026-08-10T20:19:31.579+02:00 level=INFO msg="serving MCP over HTTP" address=<IP_ADDRESS>:8000 transport=streamable-http tools=6Six tools, and a bind address redacted in the process’s own log: every log record passes through the sanitizing handler from 4.5. Guardrails, which rewrites logs only, and 127.0.0.1 matches its IP-address rule. Now ask the server, from a second terminal, what it will actually serve:
curl -sN -X POST http://127.0.0.1:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| sed -n 's/^data: //p' | jq -r '.result.tools[].name'get_incident
get_runbook
get_service_status
list_incidents
search_runbooks
search_service_logsThere is the whole surface: the same six reads from 3.1. Tools, names, descriptions, and JSON schemas unchanged, because agents/go/cmd/agent/servers.go builds the server from the tool surface the agent is composed from. The sed matters: streamable HTTP answers as server-sent events, so each JSON-RPC reply arrives on a data: line.
Predict before the next command: restart_service is a real tool in this binary, so what should a read-only server do when a client asks for it by name?
curl -sN -X POST http://127.0.0.1:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"restart_service","arguments":{"name":"inventory"}}}' \
| sed -n 's/^data: //p' | jq -c .{ "jsonrpc": "2.0", "id": 3, "error": { "code": -32602, "message": "unknown tool \"restart_service\"" } }Not “unauthorized”. Not “confirmation required”. Unknown. The tool was never registered on this server, so there is nothing here to authorize, rate-limit, or forget to check.
You just read this server’s whole tool surface off the wire, and watched a write tool refused as unknown rather than unauthorized.
No model has been involved yet: AGENT_MCP_URL is the one variable that completes the move, and 5.3. A2A Gateway is where that configuration meets a model.
Why /livez reports alive while /healthz reports unready
Leave the server running and ask how it is:
curl -s http://127.0.0.1:8000/livez
curl -s http://127.0.0.1:8000/healthz{"status":"alive"}
{"status":"unready","problems":["dataset unavailable: *fmt.wrapErrors"]}On a fresh checkout that second line is not a bug. /livez reports only that the process loop is running. Liveness restarts a container, and restarting never fixes a missing database — wiring liveness to storage turns an outage into a restart loop that deepens it. /healthz additionally opens the configured runtime store and checks that it is readable and correctly shaped. That store is the writable generation startup publishes from the immutable seed, and a fresh checkout has published none.
That gap is the design rather than an oversight: this process is a reader in every deployment. It never prepares, migrates, or publishes writable state, because a replica that migrated state under a running agent would be a data race across processes. The single writer is the A2A startup path, so you clear unready by starting that writer once: run mise run a2a — 3.6. A2A gives that server its own page — and /healthz returns {"status":"ready"} from then on.
The unready body reports the error’s Go type, never its message: a message can name a filesystem path or a driver detail, and a readiness body is served to anyone who can reach the port.
How the three transports differ, and what each allowlist refuses
Four roles sit in order along one call. The Go MCP server registers the six reads; ADK’s Go MCP toolset, the client adapter inside the agent, asks for them; a transport carries the JSON-RPC between the two; and agentgateway is an optional policy point that can observe, bound, or refuse a call without either side changing. MCP governs tool discovery and invocation; 3.6. A2A governs agent discovery and task exchange, and neither replaces the other.
This server registers tools and nothing else. MCP also defines resources — readable content a server exposes by URI — and prompts, reusable templates a client can fetch; each would be a second surface whose contents reach the model, so each would need the same allowlist, the same spotlighting, and the same reviewed provenance as a tool result before it could ship here. 6.4. Platform Tools takes the deployment step this page stops short of.
The server supports three transports, chosen by one switch:
func (s *Server) Serve(ctx context.Context) error {
switch s.options.Transport {
case TransportStdio:
return s.serveStdio(ctx)
case TransportSSE, TransportStreamableHTTP:
return s.serveHTTP(ctx)
default:
// Unreachable: New resolved and validated the transport. Reported as an
// error anyway, because "unreachable" is a claim about today's code.
return fmt.Errorf("%w: unsupported transport %q", ErrInvalidOptions, s.options.Transport)
}
}Serve dispatches on the configured transport and folds both HTTP flavors into one path, and its default branch returns an error even though New already validated the value — because “unreachable” is a claim about today’s code. stdio is the default local path: the client launches this same agent binary with the mcp subcommand as a child process, which inherits only the environment its parent deliberately gave it, talks over standard input and output, needs no shell or second runtime, and publishes no port at all. Streamable HTTP serves /mcp and is what the gateway and the Kubernetes deployment use. SSE is the HTTP+SSE transport that revision 2026-07-28 reclassified as formally Deprecated on 28 July 2026, opening a twelve-month minimum removal window; it exists for older clients and new deployments should not choose it.
The HTTP surface defaults to loopback and applies Host and Origin allowlists, a header timeout, and a bounded drain on shutdown. The Host allowlist is DNS-rebinding protection rather than paperwork: a page on an attacker’s site can resolve a name it controls to 127.0.0.1 and then talk to a local server as though it were same-origin, and a closed list of authorities is what stops it. Setting it to * is refused outright.
The protocol revision you speak is whatever the pinned github.com/modelcontextprotocol/go-sdk negotiates with the peer. The specification’s current revision is 2026-07-28, published 28 July 2026, and it removed protocol-level sessions and the initialize handshake outright — so a revision number decides the wire shape rather than a feature list. Do not copy one into configuration; agents/go/go.mod, go.sum, the installed SDK source, and the handshake tests are the authority, and an SDK upgrade has to re-run both the stdio and HTTP protocol tests plus the gateway smoke. That negotiation is the portability argument and the risk at once: independently deployed peers evolve behind an agreed protocol, while tool names, descriptions, and schemas still arrive as model input. A server you connect to writes prose into your prompt.
Which is why the client side pins its own list:
func NewMCPToolset(cfg MCPConfig) (tool.Toolset, error) {
transport, credentials, err := cfg.transport()
if err != nil {
return nil, err
}
built, err := mcptoolset.New(mcptoolset.Config{
Transport: transport,
Auth: credentials,
// The in-Config filter rather than tool.FilterToolset. Both work here —
// unlike the skill toolset, this one has no catalog injection for an
// external wrapper to drop — and the in-Config one runs before the tool
// is wrapped, which is the earlier of the two boundaries.
ToolFilter: tool.AllowedToolsPredicate(MCPReadToolNames()),
})
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrMCP, err)
}
return built, nil
}NewMCPToolset builds the toolset with tool.AllowedToolsPredicate(MCPReadToolNames()) inside the config, so filtering happens before a tool is wrapped and a rejected declaration never reaches a request. An HTTP endpoint gets a bounded http.Client and an optional bearer credential; a child command gets the SDK’s command transport and no network credential; supplying neither is a configuration error rather than a silent no-op.
Only the conversational entrypoint switches to MCP, and only when AGENT_MCP_URL is set. The workflow and coordinator specialists keep their local restricted sets, and the guarded writes stay in this process in every composition: a remote server cannot acquire action authority because the read path moved behind a protocol.
Flipping it changes the implementation location and nothing the model can see. Names and schemas are identical, the client discovers declarations at connect time, keeps only the allowlisted names, and applies the tool timeout. What the agent gives up is the local resilience wrapping: the deadline, retries, and circuit breaker around every in-process call then belong to the gateway, the trade Chapter 5 makes explicit.
Three independent points hold that surface read-only: the server surface check refuses to start with a non-read tool registered, the client allowlist drops any declaration it did not expect, and the confirmation requirement on a guarded write cannot be met in a process with no session to pause, no event stream to publish a request on, and no human listening.
Your turn: try to put a write tool on the MCP server
Any one of those three would hold alone. Try to get a write tool onto this server and see what stops you.
- Mode:
temporary experiment— you are weakening a security boundary to watch it fail, and you will put it back. - Goal: defeat the server surface check, then the client allowlist, and find what still stands behind them.
- Files to touch:
agents/go/cmd/agent/servers.goandagents/go/compose/mcp.goonly. - Preflight:
git diff --quiet -- agents/go/cmd/agent/servers.go agents/go/compose/mcp.go, andcd agents/go && go test ./mcpserver ./composegreen. - Steps: defeat the three points in that order. Each step tells you what to change and what to predict before you read the output.
- Step one — the server refuses. In
servers.go, appendsurface.ActionTools()to theToolslist the MCP server is built with, then runmise run mcp:http. Predict the message before you read it:
agent: incomplete MCP server configuration: tool "restart_service" is not in the read allowlist (list_incidents, get_incident, get_service_status, search_service_logs, get_runbook, search_runbooks); the MCP surface is read-only and a server cannot widen it
incomplete MCP server configuration: tool "resolve_incident" is not in the read allowlist (list_incidents, get_incident, get_service_status, search_service_logs, get_runbook, search_runbooks); the MCP surface is read-only and a server cannot widen itTwo problems, not one — ActionTools() returns both guarded writes, and the check reports every offending tool at once rather than dying on the first.
- Step two — widen the allowlist. Add
tools.RestartServiceToolNametoMCPReadToolNames()incompose/mcp.go, keep just that one action tool on the server, and start it again. Now the startup log saystools=7and the listing request from earlier returns seven names,restart_serviceamong them. Two of the three points are gone. - Step three — call it. Send the
tools/callrequest from earlier again and read what comes back:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "this tool requires human confirmation, which the MCP server cannot request: call it through the agent instead"
}
],
"isError": true
}
}That is the confirmation requirement: an MCP server that could run an approval-gated action would have made approval optional.
- Gate that proves completion: you can state, from your own three runs, which check produced each of the three outcomes — startup refusal, seven advertised tools, and a call that fails closed — and why the third one cannot be configured away.
- Final state:
git restore -- agents/go/cmd/agent/servers.go agents/go/compose/mcp.go, thencd agents/go && go test ./mcpserver ./composegreen again andmise run mcp:httpback totools=6.
Deeper: adding a third-party MCP server safely
Treat someone else’s server as untrusted code and untrusted instruction data, because it is both.
- Review provenance, licence, transport, authentication, and every tool schema before you connect.
- Create a closed client-side name allowlist, exactly as
MCPReadToolNamesis. - Give the server only the data and network access its tools need.
- Apply deadlines, size bounds, redaction, and audit records on your side of the wire.
- Add a test where the server advertises an extra tool, and confirm it never reaches the model.
What you can do now
- You served the agent’s six reads from their own process and listed them from the wire, unchanged in name and schema.
- You can explain why
/livezsays alive while/healthzsays unready on a fresh checkout, and which process is allowed to fix it. - You defeated two of the three read-only enforcement points and watched the third refuse the call anyway.
cd agents/go && go test ./mcpserver ./composepasses, covering the transports, probes, drain, and the exact accepted tool names.
A capability behind a protocol is one another team can consume and you can put a gateway in front of — and you know, from having tried, that moving it did not move its authority.
Continue to 3.4. Memory, where remembered facts get a store, an owner, and a lifetime.