Governed Tasks (Task Relay)
Governance for asynchronous MCP tasks: Hangar relays an upstream-created task and interposes ownership, digest re-verification, a task_id-keyed audit chain, and a fail-closed mid-flight consent gate on its lifecycle -- without ever executing the task.
Landing in 2.0 / on the v2 preview. Everything on this page ships on the v2 preview (prerelease
2.0.0a1,mcp==2.0.0b2) and is not in released1.6.0. Tasks were promoted out ofmcp.server.experimentalinto a first-class negotiated extension only in the SDK v2 beta, which is what makes governed relay buildable at all (see ADR-014). On1.6.0the observed behavior is still a cleanTaskRelayNotSupportedrejection of any upstream task handle.
Overview
Synchronous tools/call has been the governed call-shape from the start: identity, digest pins, egress rules, and the audit stream all attach at the proxy chokepoint. Tasks are the one call-shape that was dormant -- an upstream can answer a call by handing back a task_id and finishing the work later, out of band, and until v2 that async path was ungoverned.
Governed Tasks close that gap. The model is relay-with-governance, not executor:
- Hangar relays a task an upstream created and interposes governance on its lifecycle --
tasks/get,tasks/result,tasks/cancel,tasks/list. - Hangar does not create tasks, run a scheduler or GC, store results, or cross a worker-thread → main-loop execution bridge. There is no job runner. The proxy stays a proxy.
This is the same species distinction ADR-008 drew and ADR-014 preserves: Envoy does not run your cron jobs. Hangar governs the call path of a task an upstream owns; it never becomes the thing that owns execution. What v2 lifts is only ADR-008's "permanently no relay" absolutism -- not the executor prohibition.
The relay seam: every relayed task is locally known
The failure mode this design exists to exclude is the dead handle. Pass an upstream task_id straight through to the client and you have promised a handle you cannot answer for: a later tasks/get finds nothing, and the client gets a misleading "Task not found" for a task that is genuinely running upstream. ADR-008/#368 papered over that with an honest rejection. ADR-014 replaces the rejection with a record.
On relaying an upstream CreateTaskResult, GovernedTaskStore.relay_and_govern() does registration and provenance as one lock-held critical section -- before the handle reaches the client:
- Bind the owner (tenant + principal) from the request identity, cross-checked against the authorized owner.
- Pin the tool digest carried on the synchronous invoke path.
- Store the upstream-truth
Tasksnapshot. - Emit the
TaskCreatedprovenance head.
If the TaskCreated publish raises, the whole registration is rolled back -- zero governed state survives, no orphan binding, no headless provenance head. And because the record is written before the client ever sees the handle, a relayed task_id is always locally known. The dead-handle failure mode is structurally excluded, not merely tested against.
The store holds governance metadata only -- never a result payload, never execution state. Task ids are unique only per upstream, so every entry is keyed on the composite TaskKey = (target_server_id, task_id); two upstreams may legitimately mint the same task_id.
The four serving handlers
Once a task is relayed, a client follows up through four v2-native tasks/* request handlers registered on the low-level MCP server. Every handler is fail-closed, ownership-scoped, and upstream-truthful -- it never fabricates state.
| Handler | Wire method | What it does |
|---|---|---|
| Poll | tasks/get | Relays to the owning upstream, syncs the local snapshot from the upstream status verbatim, returns it flat. A working → completed transition emits TaskCompleted exactly once. An input_required status triggers the consent gate. An upstream error returns the local snapshot unchanged. |
| Fetch result | tasks/result | Re-verifies the pinned tool digest fail-closed before relaying, then reconstructs the payload by validating the upstream result into CallToolResult. Digest drift fails the task and raises. |
| Cancel | tasks/cancel | Best-effort relay of tasks/cancel. Retires the entry only on a confirmed upstream cancellation (clean result, status cancelled or absent, no error); otherwise keeps the entry and returns its true current status. Confirmation emits TaskCancelled once. |
| List (owner-only) | tasks/list | Returns the caller's owned snapshots as a single page. The inner/upstream cursor is never forwarded -- it could identify another tenant's task -- so nextCursor is always absent. |
A client sends only a bare task_id. The handler resolves it to the composite key via find_owned_key, which is ownership-fail-closed: a task_id the caller does not own is indistinguishable from one that does not exist -- both raise the same INVALID_PARAMS "Task not found". No existence leak.
Identity bridging. On streamable-HTTP the transport runs the low-level request handler in a per-session task decoupled from the ASGI auth wrapper, so the ambient identity is not propagated in. Each handler bridges the authenticated principal off the FastMCP request context into identity_context_var for the duration -- exactly as the hangar_call batch path does (#387) -- and asyncio.to_thread copies that context into the worker thread where the (threading-locked) ledger runs. An absent principal leaves the caller unattributed, which is fail-closed downstream: an unattributed caller can only ever reach unattributed tasks.
Per-task ownership and digest-pinned results
Authorization is fail-closed and runs on every public path through a single authorize chokepoint, delegating to the TaskOwnershipRegistry:
- Reads (
get_task,list_tasks) returnNone/ exclude the entry on denial -- a denied caller cannot tell "not found" from "not yours". - Mutations (
update_snapshot,delete_task, the terminal transitions) raiseMcpErrorINVALID_PARAMSwith the same"Task not found: <id>"message. Denial never confirms existence. - Anonymous / system path: with no identity bound the caller is
TaskOwner(None, None)-- it can reach only unattributed entries, and can never reach a task owned by an attributed tenant.
The interesting half is supply-chain integrity across the async boundary. A synchronous call is governed against the tool schema digest the caller authorized. But a task finishes later -- and in that gap the tool's schema can drift. So the digest pinned at relay time is re-verified fail-closed every time a result is fetched: tasks/result calls _verify_pinned_digest before it relays. If the tool's current digest no longer matches the pin -- or the current schema cannot be verified at all -- the task is failed, a DigestMismatchInTask event is emitted, and an McpError is raised.
This is the ADR-008 "zombie" closed for the async case: a task can never complete against a tool contract the caller did not authorize. Digest drift fails the task; it does not merely refuse the result and leave a permanently-unavailable handle hanging.
The Task* audit chain
task_id → provenance is an append-only event chain, built on ADR-002 event sourcing. Every event is keyed by task_id and carries tenant_id + correlation_id, threaded from the TaskCreated head so the whole chain shares one provenance thread:
| Event | Emitted when |
|---|---|
TaskCreated | The relay seam registers the task (the provenance head, written under the registration lock). |
TaskCompleted | A working → completed transition is observed on poll -- deduplicated atomically, so repeated polls emit at most one. |
TaskCancelled | A tasks/cancel is confirmed by the upstream -- deduplicated. |
TaskFailed | The task is failed closed: digest drift, consent denial, or an evicted-but-still-live binding (TaskFailed('evicted')). |
TaskConsentDecided | A mid-flight consent decision resolves -- granted or denied -- carrying the input_key and the principal_id that was prompted. |
DigestMismatchInTask | Pinned-digest re-verification finds drift (paired with the TaskFailed). |
The full lifecycle of any relayed task is reconstructable from the event stream. This is the forensic non-repudiation the product thesis already sold for synchronous calls -- now extended to cover the async call-shape that was the last one left dark. (ADR-014 Decision 3 names TaskInputRequired in the lifecycle set as well; the emitted provenance on the v2-preview code path is the six events above.)
The mid-flight consent gate (#322)
The consent gate is the first activated beneficiary of the relay seam, and the one genuinely interactive control in the stack.
When a relayed task's upstream status becomes input_required, the task is paused mid-flight waiting on a human decision. On the 2025-11-25 protocol there is no inbound tasks/update to carry that decision back, so tasks/get resolves it synchronously, in-handler:
- The tenant is already authorized (structurally, above the gate).
- A deterministic
input_keyis derived from the upstream's pending input request(s) -- stable across concurrent polls, so a second in-flighttasks/getmaps to the same gate key and does not double-prompt. - The downstream client is elicited for consent over
ctx.session-- a real, interactive human-in-the-loop prompt. If the client did not negotiate the elicitation capability, there is no back-channel to consent the caller: fail-closed immediately. - The gate opens only on a confirmed accept -- consent is obtained before the gate opens, so there is no open-then-decide race.
- On accept, the answer is relayed upstream, the single-use consent is consumed only after a confirmed relay,
TaskConsentDecided(granted=True)is recorded, and the post-input upstream status is re-synced. - On any non-accept outcome -- decline, cancel, no back-channel, elicitation error, missing capability -- the task is terminally failed closed, a best-effort
tasks/cancelis relayed upstream, andTaskConsentDecided(granted=False)is recorded.
A paused task is therefore never left hanging. Every branch is terminal: it either proceeds with recorded consent or fails closed with a recorded denial. A transient upstream relay failure is the one recoverable case -- it discards the gate without consuming the consent and does not fail the task, so a retry re-elicits and completes.
What this is not
Two distinctions the code enforces and the positioning depends on. Keep them exact.
This is the only interactive consent flow -- the synchronous L7 gate is not. The egress policy's requireApproval (see Egress Policy) fails closed: a gated synchronous tools/call is blocked pending an out-of-band approval. It is a hard gate, not an interactive approval queue -- nothing prompts a human and waits. The async consent gate here is the genuinely interactive path: it elicits a live human decision mid-flight and routes it back. Do not conflate them.
Hangar relays and governs; it does not execute. No scheduler, no result store, no GC/TTL correctness, no cancellation-race ownership, no worker → main-loop context bridge. Governance binds at the proxy/store seam on the request path -- the same seam that governs synchronous tools/call -- so the "one bug and governance silently does not bind" failure mode a background execution thread would introduce simply does not exist to break.
Coming: the modern tasks protocol
The 2026-07-28 protocol handshake and the SEP-2663 Tasks reshape change how mid-flight input is resolved: the client drives an inbound tasks/update carrying its input, rather than the server eliciting synchronously inside tasks/get. On that path the governed input handler is tasks/update, not tasks/get, and tasks/list is removed from the served surface.
This is forward-compatible plumbing, not live behavior. The serving surface tracks the negotiated protocol by registering each handler only while the SDK defines its type -- tasks/list only where ListTasksResult exists, the inbound tasks/update only where UpdateTaskRequest exists -- so the served methods follow the negotiated version without a version bump, and behavior on the b2 beta is byte-identical. The modern branch stays unreachable on a 2025-11-25 session (the version probe is fail-safe to the pre-modern synchronous path). Treat 2026-07-28 / SEP-2663 as coming, never as shipped.
Limitations and notes
- v2 preview only. Not in released
1.6.0. The seam ships dark behind therelay_tasks_enabledkill-switch, retained for a fast per-deployment rollback. - Behavior is unchanged until an upstream emits a task. Activation is per-upstream, on the first real task an upstream emits (ADR-014 Decision 5). A deployment whose upstreams never emit tasks observes no difference; the
taskscapability is advertised atINITIALIZEonly once the seam is live -- "do not advertise what does not run." - The ledger is in-memory. An in-memory
task_idmapping suffices for a relay; a durable/distributed task store remains the executor's problem and stays out of scope. The ownership registry and digest guard are TTL/LRU bounded, and an evicted still-live binding is failed closed (TaskFailed('evicted')) rather than silently vanishing. - Cancellation is best-effort relay. Hangar forwards
tasks/canceland retires the entry only on confirmation; it takes on no cancellation-race ownership beyond the relay. - Consent requires a back-channel. The mid-flight gate needs the downstream client to have negotiated the elicitation capability; without it, an
input_requiredtask fails closed.
See also
- ADR-014: Tasks are Relayed With Governance -- the decision, the superseded ADR-008 absolutism, and the activation record.
- ADR-008: Tasks Relay-Only -- the prior "relay-only, permanently" decision this supersedes in part.
- Egress Policy -- the synchronous L7 governance, including the
requireApprovalgate this page contrasts with. - Tool Invocations with hangar_call -- the synchronous call path whose identity-bridging pattern (
#387) the task handlers reuse.