2026-08-04 · engineering
Federating MCP tasks: affinity routing over opaque ids
The MCPtasks extensionstandardized long-running work: a tool call mints a task, and the client polls it with nothing but an opaque id — across requests, sessions, even processes. That's great news for clients — and a real problem for anything in the middle. This post is about what task ids do to a gateway fronting N task-running servers, and the mechanism that solves it. Everything below ships infold, the open-source enterprise MCP gateway, and every claim links to the code.
The problem, concretely
fold presents many MCP servers as one governed virtual server: one endpoint, one merged tool list, namespaced names (gh__create_pr, jobs__start_job).
client ──▶ fold ──▶ github-tools (platform team)
├──▶ ml-search (the acquisition)
└──▶ jobs (task-minting batch server)
tools/call jobs__start_job ──▶ jobs mints "job-42"
tasks/get { "job-42" } ──▶ ...which upstream owns this?A client calls jobs__start_job; the task is born on the jobs server and its id travels back through fold. Every latertasks/get, tasks/cancel,tasks/result, and tasks/update for that id must reach that server — and the request carries nothing but the opaque id. No namespaced tool name, no routing hint, and no session to lean on: clients persist task ids and come back for them tomorrow, on a fresh connection, possibly through a different gateway instance.
The answers that don't work
Rewriting the id — smuggling the owner into the id the client sees — breaks every client that persists one the moment your encoding changes, and turns an opaque protocol object into a contract you now own forever. fold treats task ids exactly like resource URIs: never rewritten.
Broadcasting every task call to all N upstreams costs N× amplification on every poll, and it's a correctness bug, not just a cost: tasks/cancel fanned out is a destructive write sent to N−1 servers that never minted the task, and two upstreams can independently mint colliding ids.
Punting to the client ("remember which server you created it on") recreates the N×M topology sprawl the gateway exists to remove — and it breaks outright when policy deliberately hides topology from the client, which in an enterprise deployment it does.
The mechanism: an affinity index with a probe fallback
Ownership is remembered instead of encoded:
- Recorded at mint. A
tools/callresult that advertises a task it created pinstaskId → upstreamas it passes through, so the polls that follow skip discovery entirely. - Probe fallback. A task fold never saw minted — another gateway instance, an evicted record, an id shared out-of-band — is located by fanning a read-only
tasks/getout once: the owner answers, everyone else is a healthy "not mine". Mutating methods are never fanned out; fold locates first, then acts on the owner alone. - Ownership is principal-bound. The mint records who created the task. Another caller's
tasks/getfor that id answers exactly like an unknown id — no existence leak, no probe spent on it — andtasks/listmerges every upstream but shows each principal only its own tasks. - Honest misses. An id no upstream recognizes answers
-32002, and the owning upstream's own errors pass through verbatim — the gateway stays invisible.
// gateway/tasks.go — the poll path: affinity, then probe
if v, ok := g.taskOwner.Load(taskID); ok {
// a task minted for a different principal is answered exactly like
// an unknown id — the denial must not reveal existence
return u.callTask(ctx, method, raw)
}
// Probe: read-only tasks/get across upstreams — the owner answers,
// everyone else is a healthy "no". Never fan out a mutating method.
owner := g.locateTaskOwner(ctx, rt, taskID)gateway/tasks.go#L148 — the real thing, including the probe
Governance rides along for free: a task-creating call is a tool call, so deny-by-default policy applies at mint, and every poll that follows is an audit record like any other request.
What about fan-in?
The extension's companion story is subscriptions/listen — one notification stream south, N streams north, merged. fold doesn't implement it yet, and says soin the READMErather than in a footnote: the official Go SDK currently supports the 2026-07-28 protocol on its streamable HTTP server only in stateless mode, and fold's session-keyed bridging (sampling, elicitation, per-client streams) requires stateful sessions.
What ships today is the fan-in the protocol already exercises: list-changed and resource-updated notifications from every upstream fan out to the clients that care, proxied results are origin-tagged (_meta["run.fold/upstream"]), and adrift canary in the test suitefails the moment the SDK lifts the restriction — the gap closes when it can, and CI notices before we do.
The transferable lesson
None of this is MCP-specific. Federating any protocol whose resources outlive their transport reduces to the same shape: an ownership index for stateful resources, recorded at mint, with a read-only probe path for the records you'll inevitably miss.Fronting N job APIs with one endpoint, aggregating webhooks, sharding a queue behind a facade — same checklist:
- Namespace what you own at the boundary; never rewrite identifiers clients persist.
- Record ownership at mint time; keep a probe path for the records you'll inevitably miss.
- Probe with reads, act with writes — never fan out a mutating method.
- Bind ownership to the caller, and make a denial indistinguishable from a miss.
- Fail closed on index misses, and audit them.
Try it
go run github.com/fold-run/fold/cmd/fold@latest — ordocker run ghcr.io/fold-run/fold — puts the gateway in front of your servers in a minute (walkthrough). The integration suite runs the whole story against real MCP servers from the official Go SDK —gateway/tasks_test.gois the receipts. docs.fold.run ·github.com/fold-run/fold(Apache-2.0). The conformance results arein CI, on every merge — check them.