Govern an async task end-to-end
Updated July 22, 2026
Govern an async task end-to-end
An MCP task answers a long-running tools/call by handing you back a
promise – a task_id – with the real work finishing minutes later, on a
tasks/get you poll out of band.
This is a hands-on tour of what Hangar does with that promise. Hangar relays the task an upstream owns and governs its lifecycle at the same proxy seam that already governs synchronous calls – it never runs the task, never schedules it, never stores its result. You will mint a task, follow it up, answer a mid-flight input request, read a digest-checked result off the poll, and follow the decision through the events it emits.
Every method below is a real wire call from the SEP-2663 set – tasks/get,
tasks/update, tasks/cancel. There are only three: the SEP removes
tasks/result and tasks/list, and Hangar answers both with -32601. The
client -> Hangar and Hangar -> client blocks are the JSON-RPC frames on the
wire – you do not type them by hand. You drive them through whatever MCP client
you have connected.
Before you start
You need 2.0.0 or newer, one upstream that answers a tool call with a task, and a client that can actually reach this surface. That last one is a real constraint, not a formality:
- It must speak 2026-07-28. The
initializehandshake cannot negotiate that generation – it tops out at2025-11-25– so the client reachestasks/*only on the per-request-envelope path. - It must declare
io.modelcontextprotocol/tasks. Without it Hangar answers-32021and tells it exactly what to add. - It must send
Mcp-Name: <taskId>on everytasks/*. SEP-2663 mandates it so an intermediary can route a poll without reading the body, and Hangar enforces presence, not just agreement. A missing or contradictory header is-32020.
relay_tasks_enabled is live by default; set it to false for a
per-deployment rollback. It was briefly off after the surface was found
advertising a wire it did not serve, and went back on once that wire was served
and verified end to end – which is the thing worth checking before ever moving
it again. The flag is one line; the mismatch is what actually bites.
Behavior is unchanged until an upstream actually emits a task. A deployment
whose upstreams never task observes no difference, and the extension is
advertised on server/discover only once the seam is live – Hangar does not
advertise what does not run.
Step 1 – Point Hangar at a tasking upstream
Register the upstream the way you register any MCP server. Nothing here is
task-specific; the relay engages on its own the first time this upstream answers
a call with a task_id.
# ~/.config/mcp-hangar/config.yaml
mcp_servers:
reporter:
mode: subprocess
command: [npx, -y, "@example/mcp-server-reporter"]
idle_ttl_s: 300
Step 2 – Govern the call path with an egress policy
The task inherits the governance of the synchronous call that spawns it, so put the policy on that call. Here we require approval for the report-generating tool and hold the upstream to a single FQDN.
apiVersion: mcp-hangar.io/v1alpha2
kind: MCPEgressPolicy
metadata:
name: reporter-policy
namespace: prod
spec:
mode: Enforce # Audit (default) observes; Enforce blocks
targetRef:
kind: MCPServer # or MCPServerGroup
name: reporter
upstreams:
- name: reporter-api # rule name, required + unique in the policy
match:
host: api.reporter.example # FQDN -> needs the Cilium flavor
tools:
requireApproval: ["generate_*"]
FQDN enforcement requires Cilium. A vanilla Kubernetes NetworkPolicy
cannot match on DNS names, so a hostname upstream is only enforceable under the
Cilium backstop flavor – the operator compiles this into a
CiliumNetworkPolicy with toFQDNs. Under any other CNI, list the upstream as
a literal IP/CIDR instead, or the hostname is failed closed (denied, never
opened to “any destination”) and surfaced as
Degraded/FQDNUpstreamsUnenforceable.
Note what requireApproval does not do on the synchronous L7 path: it
fails closed – a gated tools/call
is blocked pending an out-of-band approval, not queued for a live click. The
async gate in step 5 is a different mechanism, but it is not a live click either:
it governs an answer the client volunteers. Nothing in Hangar prompts a person
and waits.
Step 3 – Mint the task
Call the long-running tool. The upstream answers not with a result but with a
CreateTaskResult carrying a task_id.
// client -> Hangar
{ "method": "tools/call",
"params": { "name": "generate_report", "arguments": { "range": "90d" } } }
The instant Hangar relays that CreateTaskResult, and before the handle
reaches you, it does two things in one lock-held critical section: it writes a
governance-ledger entry keyed on the composite (target_server_id, task_id),
and it emits a TaskCreated provenance head. If the event publish fails, the
whole registration rolls back – zero governed state survives. There is no
window in which a live handle is untracked.
// Hangar -> client (task_id is now a governed object)
{ "result": { "task": { "taskId": "tk_9f2a…", "status": "working" } } }
That composite key matters: task_id is unique only per upstream, so the ledger
binds it to this upstream and your identity. You will hand back only the
bare task_id from here on; Hangar resolves the rest.
Step 4 – Follow it up
Poll the task. tasks/get authorizes you as the owner, relays to the upstream,
and syncs the local snapshot from verbatim upstream truth – Hangar copies
the status, it never synthesizes one. An upstream error leaves the snapshot
untouched rather than fabricating a state.
// client -> Hangar
{ "method": "tasks/get", "params": { "taskId": "tk_9f2a…" } }
Two ownership properties are load-bearing here. A task_id you do not own is
indistinguishable from one that does not exist – both return the same
-32602 “Task not found”, so the ledger is never a side channel for
enumerating another tenant’s tasks.
There is no listing call to abuse either: SEP-2663 removes tasks/list, and
Hangar does not register it. It answers -32601, like tasks/result.
// client -> Hangar
{ "method": "tasks/list", "params": {} }
// -> {"error": {"code": -32601, "message": "Method not found"}}
Step 5 – Answer the mid-flight input request
Some tasks pause mid-flight and ask for something – a confirmation before they
touch production. In MCP that surfaces as an input_required status, and on the
SEP-2663 wire the task tells you what it wants: the snapshot carries an
inputRequests map, keyed so you can answer it.
// Hangar -> client (a tasks/get poll)
{
"taskId": "tk_9f2a…",
"status": "input_required",
"resultType": "complete",
"inputRequests": { "consent": { "message": "Proceed against production?" } }
}
You answer by driving an inbound tasks/update keyed on those same names. That
update is the consent – there is no prompt, and Hangar never asks you
anything on its own initiative:
// client -> Hangar
{
"method": "tasks/update",
"params": {
"taskId": "tk_9f2a…",
"inputResponses": { "consent": { "content": { "approved": true } } }
}
}
// -> {"resultType": "complete"} an empty acknowledgement
The governance is in the ordering, and it is strict. The gate opens before
your answer reaches the upstream, and the single-use consent is consumed only
after a confirmed relay. A foreign tenant is refused above the gate, before
anything opens or relays. TaskConsentDecided records the decision with the
principal that made it.
A transient upstream refusal is the one recoverable branch: it discards the gate without burning the consent and does not fail the task, so a retry re-drives the update and completes.
The acknowledgement is empty on purpose – poll tasks/get for the state that
resulted.
This used to be an interactive prompt. Until the SEP-2663 realignment, Hangar resolved a pause by eliciting your client over the live session and failing closed on decline, cancel, a missing elicitation capability or any error. That belonged to the 2025-11-25 wire, which Hangar no longer serves. Consent is still governed and still fail-closed; it is now gated on an update you volunteer rather than on a question Hangar asks.
Step 6 – Read the digest-checked result off the poll
There is no second call for the payload. SEP-2663 folds the old tasks/result
round trip into the poll: a completed task carries its outcome inline.
Between mint and completion the upstream tool could have been redeployed and its
schema drifted, so tasks/get re-verifies the pinned tool digest fail-closed
before it hands any payload over.
// client -> Hangar
{ "method": "tasks/get", "params": { "taskId": "tk_9f2a…" } }
// -> { "taskId": "tk_9f2a…", "status": "completed", "resultType": "complete",
// "result": { "content": [ … ], "isError": false } }
If the tool’s current digest no longer matches the one pinned at mint – or the
current schema simply cannot be verified (unverifiable is treated as drifted) –
the task is failed, a DigestMismatchInTask event is emitted, and an error
propagates. The result is never handed over, and the upstream is never even
asked for it. This closes the zombie: a task that completes against a contract
you never authorized.
Hangar held none of the payload – it lived upstream the whole time. If your
upstream is on the older design and still keeps it behind tasks/result, Hangar
fetches it on your behalf so the inline field is populated either way.
Step 7 – Read the decision back
Every transition you just drove emits a task_id-keyed event, each carrying
tenant_id and correlation_id:
TaskCreated # step 3 -- at relay time, before the handle reached you
TaskConsentDecided # step 5 -- accept, with the prompted principal_id
TaskCompleted # working -> completed, deduped to fire exactly once
TaskFailed # the fail-closed paths (consent denied, digest drift, eviction)
TaskCancelled # only on a confirmed upstream cancel
DigestMismatchInTask # step 6 -- the async supply-chain event
The consent decision in particular is recorded as TaskConsentDecided –
granted or denied – keyed by task_id and attributing the principal_id that
was actually prompted. The full lifecycle of the task is now reconstructable
from that stream: who invoked what, under which pinned contract, with what
verdict. This is the same forensic non-repudiation the synchronous call path
already gives you, extended to the one call-shape that used to be dark.
What you just did
You took an async task through its entire governed lifecycle without Hangar ever
running it. You minted a task and watched it become a first-class governed
object the instant it was relayed; you followed it up through owner-scoped,
upstream-truthful handlers; you answered a mid-flight input request through the
governed tasks/update and had that decision recorded; you read a result off the
poll that was re-checked against its pinned contract before it was handed over;
and every step of it emitted an attributed event your telemetry backend can
stitch back together.
And Hangar took on no executor liabilities to do it: no scheduler, no result store, no GC correctness, no cancellation-race ownership, no worker-to-main-loop bridge. It relayed and it governed. The proxy stayed a proxy.
Grounded in ADR-014
(relay-with-governance), building on ADR-002
(event sourcing) and ADR-004 (digest
pinning). Shipped in 2.0.0 (mcp==2.0.0); everything is MIT and
self-hosted – no SaaS.