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:

// 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:

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.