The primitives are done, the four the client calls and the three the server calls back. What is left is the plumbing they all share. Five cross-cutting utilities run underneath every method: progress reporting, cancellation, ping, pagination, and the reserved _meta field that carries some of them. None is a primitive on its own, and all five show up in real traffic, so this post reads each on the wire.
This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source
txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision2025-11-25, with the official Go SDK atv1.6.1.
notifications/progressprogress, optional total and messagenotifications/cancelledrequestId and optional reasonping → {}cursor / nextCursor*/list§_meta: The Reserved Channel
Start with _meta, because the others ride on it. Every request, result, and notification may carry a reserved _meta object for metadata that is not part of the method’s own parameters. The protocol reserves a few keys inside it, and the one that matters here is progressToken. When a client wants progress on a call, it does not add a parameter to the call; it puts a token in _meta:
{ "method": "tools/call",
"params": { "_meta": { "progressToken": "task-42" }, "name": "long_task", "arguments": {} } }
That _meta block is how a request opts into a feature without changing its shape. The rest of params is exactly what long_task expects; the progress token rides alongside.
§Progress
A long call that reports nothing is indistinguishable from a hung one. Progress fixes that. Because the client sent a progressToken, the server may emit notifications/progress carrying that token, and the client matches each notification back to the call it belongs to. Two of them, captured from a six-step task:
{ "method": "notifications/progress",
"params": { "progressToken": "task-42", "progress": 1, "total": 6, "message": "processed 1 of 6" } }
{ "method": "notifications/progress",
"params": { "progressToken": "task-42", "progress": 2, "total": 6, "message": "processed 2 of 6" } }
progress must increase on each notification, even when total is unknown, in which case total is left out. The message is optional human-readable detail. Progress is also a liveness signal: an implementation may reset a request’s timeout when a progress notification arrives, because work is visibly happening, though a maximum timeout still applies so a stuck call cannot run forever by dribbling out updates.
§Cancellation
If the user changes their mind mid-task, the call has to stop. Either side cancels an in-flight request by sending notifications/cancelled with the request’s id. Cancelling the task above after the second update produced this:
{ "method": "notifications/cancelled", "params": { "requestId": 2, "reason": "context canceled" } }
The requestId names the request to abandon, and the rules around it are precise. A cancellation may only reference a request issued in the same direction that is believed still in progress. The initialize request must never be cancelled. The receiver should stop work, free resources, and send no response for the cancelled request. And both sides have to handle the race: a cancellation can arrive after the response was already sent, so the receiver may ignore an unknown or finished id, and the sender ignores any response that shows up after it cancelled.
This is why cancellation is an explicit message and not just the connection dropping. A dropped transport is ambiguous, it might be a reconnect, so it must not be read as a cancellation. Stopping a request requires saying so. On the Go side, the SDK turns the incoming notifications/cancelled into a cancelled context.Context, so a tool honors it the idiomatic way:
select {
case <-ctx.Done():
return nil, nil, ctx.Err() // the client cancelled
case <-time.After(step):
}
§Ping
The smallest method in MCP is ping. It takes nothing and returns nothing:
{ "id": 1, "method": "ping" } -> { "id": 1, "result": {} }
Either side may send it to check the other is alive, and the SDK can issue it on an interval as a keepalive that detects a dead peer. It has one more distinction from the handshake post: ping is the only request legal before initialization. The same server that answers a pre-init ping with {} rejects a pre-init tools/list:
{ "id": 1, "error": { "code": 0, "message": "method \"tools/list\" is invalid during session initialization" } }
§Pagination
A server with many tools, resources, or prompts does not return them all at once. Every */list method is paginated with an opaque cursor. The first call omits the cursor; the result carries a nextCursor when more remain. A server with a page size of two returns its tools two at a time:
tools/list {} -> names: [alpha, bravo], nextCursor: "In8DAQEJ...YnJhdm8A=="
tools/list { cursor: "In8D..." } -> names: [charlie, delta], nextCursor: "In8DAQEJ...ZGVsdGEA=="
The cursor is genuinely opaque. It is a base64 blob the server encodes however it likes, and the client’s only job is to pass it back unread on the next request. When a result comes back with no nextCursor, that was the last page. The same scheme covers tools/list, resources/list, resources/templates/list, and prompts/list, and on the Go side the page size is one server option, PageSize, with the cursor handling done for you.
txn2/mcp-data-platform uses progress for exactly this. A trino_query against a large table forwards its progress back through notifications/progress, keyed to the progressToken the client supplied, so a long query is visibly working rather than apparently hung.
§What’s Next
Four of these utilities are about the mechanics of a single request. The fifth area, notifications, is bigger than _meta lets on. The next post, Logging and the Notification Family, covers logging/setLevel and the structured notifications/message a server emits, then the full set of list_changed notifications and how a server changes its tools, resources, or prompts at runtime and keeps the client in sync.
The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.