MCP Tasks, Elicitation and Apps
Explicit scope, MRTR elicitation, durable Tasks, Apps UI resources and safe cancellation on MCP 2026-07-28.
- Concepts and terms
- Tasks and commands
- Annotated code
- Diagrams
- Comparisons
- Defaults and numbers
- Troubleshooting
- Questions and tips
MRTR elicitation round trip#
Task lifecycle on a stateless core#
Task methods and signals#
| Command | What it does |
|---|---|
tasks/ | Full snapshot. Outer resultType is always complete |
tasks/ | Sends inputResponses. Returns an empty ack |
tasks/ | Records cancel intent. The ack is not a final status |
subscriptions/ | POST with a response SSE stream of task snapshots |
notifications/ | Full snapshot tagged with the listen request id |
notifications/ | Request cancel on stdio only. Never for a Task |
| Close the response stream | Request cancel on Streamable HTTP |
tasks/status, tasks/result, tasks/list | Removed. Method not found |
Drive a durable task#
service = TaskService(directory)file-backed TaskStorecreated = service.dispatch(make_request(1, "tools/call", {"name": "generate_report", "arguments": {"size": "large"}}))task_id = created["result"]["taskId"]resultType task, workingservice = TaskService(directory)new instance reloads itservice.advance_worker(task_id)working to input_requiredservice.dispatch(make_request(3, "tasks/get", {"taskId": task_id}))service.dispatch(make_request(4, "tasks/update", {"taskId": task_id, "inputResponses": {"approve_outline": {"action": "accept", "content": {"approved": True}}}}))service.advance_worker(task_id)stage 2 to completedbody, headers = make_http_request(5, "tasks/get", {"taskId": task_id})headers["Mcp-Name"]equals params.taskIdApps rules#
- Extension
io.modelcontextprotocol/ui, declared by both peers on each request.- Binding
_meta.ui.resourceUrion the tool intools/list, before any call.- Resource
ui://notes/timeline.htmlwith MIMEtext/html;profile=mcp-app.- CSP
- empty
connectDomains,resourceDomains,frameDomains,baseUriDomainsby default. - Bridge
- View sends
ui/initialize, thenui/notifications/initializedafter the host answers. - Fallback
- no ui capability gives the tool without
_meta.uiand a text result. - Cache key
ui://URI, server identity and version, content digest, principal if private.
Scope rules#
- Roots are deprecated. Put scope in a
workspaceUriargument or a resource URI. - Enforce three layers: authorization, containment and an OS sandbox.
- Normalize the URI first. Then compare path components, not string prefixes.
- Never use form mode for passwords, API keys, tokens or payment data.
- URL mode
acceptmeans the user agreed to open the URL. It is not proof of completion. - An empty
"elicitation": {}means form support. URL-only does not.
Idempotency, buffers and reconnect#
ledger = MutationLedger(Path(directory) / "mutations.sqlite3")ledger.charge("acct-7", 1200, rpc_id=41, idempotency_key="checkout-7")ledger.charge("acct-7", 1200, rpc_id=42, idempotency_key="checkout-7")ledger.executionsone execution, same receiptclassify_retry(side_effect=True, idempotency_key=None)unsafebuffer = BoundedSseBuffer(3)capacity at least twobuffer.push_final(response)final response never droppedreconnect_plan(previous_subscription_id, affected_resources, unsafe_mutation_was_in_flight=True)sendLastEventId Falseretry_delay_ms("<client>", attempt)capped at 8000 msElicitation answers#
| Factor | accept | decline | cancel |
|---|---|---|---|
| Meaning | User submitted | User refused | User dismissed |
| Server action | Validate content, continue | Complete refusal result | Stop safely |
| Nonce | Consumed with the delete | Consumed, no delete | Not consumed |
| Retry later | No | No | Yes, until expiry |
| Result | deleted: True | deleted: False | deleted: False |
Numbers and defaults#
| Setting | Default | Note |
|---|---|---|
| Protocol version | 2026-07-28 | In _meta on every request |
Task ttl_ms | 900_000 | Measured from creation |
Task poll_interval_ms | 1_000 | Minimum suggested poll delay |
server/discover ttlMs | 300_000 | cacheScope public |
tools/list ttlMs | 60_000 | Sorted by tool name |
requestState expiry | 300 | Seconds after the form |
ReplayStore max_entries | 1_000 | Full store gives -32023 |
| Backoff cap | 8_000 | 250 ms times 2 per attempt |
Errors and failures#
| Symptom | Cause | Fix |
|---|---|---|
-32602 | Missing or non-string protocol version | Send the version in _meta |
-32022 | Unsupported version string | Read supported and requested data |
-32021 | Missing form or tasks or ui capability | Declare it in this request |
-32020 | Header and body disagree | Match Mcp-Method and Mcp-Name |
| Task id gives not found | Handle sent before the store could read it | Persist before return |
| Mutation runs twice | New JSON-RPC id used as dedupe | Add a durable idempotency key |
| Hung work never ends | Progress resets every deadline | Keep an absolute maximum timeout |
| UI says stopped, worker runs | Cancel ack read as final | Poll the Task to a terminal status |
Questions#
- Who decides that a call becomes a Task?
- The server. The client only declares the extension.
- Where is the final task result?
- Under
resultin the nexttasks/getsnapshot. - Input before or after task creation?
- Before: MRTR retry. After:
tasks/update. - Does
ui/initializestart an MCP session? - No. It is the iframe bridge handshake only.
- Is an
isError: truetool result a failed Task? - No. The Task is
completed. - Resume a dropped stream with
Last-Event-ID? - No. Open a new listen request and refetch.