All concepts
TutorialCore15 minSince 2.0.0
tutorialtasksasyncconsentgovernance

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 initialize handshake cannot negotiate that generation – it tops out at 2025-11-25 – so the client reaches tasks/* only on the per-request-envelope path.
  • It must declare io.modelcontextprotocol/tasks. Without it Hangar answers -32021 and tells it exactly what to add.
  • It must send Mcp-Name: <taskId> on every tasks/*. 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.