Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
106 NOTES IN PRINT
FOLIO XCIX 2026-05-29 · 5 MIN · SHORT-FORM

Progress, Cancellation, Ping, Pagination, and _meta

The plumbing every MCP primitive shares: how a long call reports progress, how either side cancels one, how a connection is checked, how a list is paged

Diagram · folio xcix
sequenceDiagram
  autonumber
  participant C as Client
  participant S as Server
  C->>S: tools/call { _meta: { progressToken } }
  S--)C: notifications/progress (1 of 6)
  S--)C: notifications/progress (2 of 6)
  C--)S: notifications/cancelled { requestId }
  Note over S: stop, free resources, send no response

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 revision 2025-11-25, with the official Go SDK at v1.6.1.

Utility
On the wire
Carries
progress
notifications/progress
a token, progress, optional total and message
cancellation
notifications/cancelled
a requestId and optional reason
ping
ping{}
nothing; an empty request, an empty result
pagination
cursor / nextCursor
an opaque token, on every */list
_meta
a field on any message
reserved metadata, including the progress token

§_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.

← back to all notes