MCP tasks, URL approval and OAuth¶
Available · pip install "agent-adapter[mcp]" · mcp 2.3+ · MCP 2026-07-28
Three MCP 2026-07-28 features on top of MCP:
| Feature | Side | Turn it on |
|---|---|---|
tasks extension (io.modelcontextprotocol/tasks) |
server and client | mcp_server(..., tasks=TaskOptions(...)); mcp_client(..., tasks=True) |
| URL-mode elicitation for approvals | server | TaskOptions(approval_url="https://…/{approval_id}") |
| OAuth with Client ID Metadata Documents | client | mcp_client(url, auth=oauth_client(...)) |
Tasks: confirm tools through any tasks-capable host¶
Without tasks, a pending approval comes back as approval required: <id> with resume keys in
_meta. A host that drops those keys starts a new approval on every retry, so the tool never
runs. With tasks, the pending call is a task handle. The host polls tasks/get with the task id
only, and the task runs the tool itself once your reviewer decides.
store = MemoryApprovalStore() # reviewers decide here (or over the HTTP server)
server = mcp_server(
remote,
executor=Executor(approval=DeferredApproval(store)),
tasks=TaskOptions(threshold_s=5.0, poll_interval_s=0.05),
)
sequenceDiagram
participant H as MCP host (declares tasks)
participant S as mcp_server
participant E as Executor + DeferredApproval
participant R as Reviewer
H->>S: tools/call send_invoice
S->>E: run_spec
E-->>S: ApprovalPending(apr_1)
S-->>H: CreateTaskResult(taskId, working, "waiting for approval apr_1")
R->>E: store.decide(apr_1, approved)
loop every poll_interval_s
S->>E: store.get(apr_1)
end
S->>E: run_spec again (same run and call ids: consumes apr_1 once)
H->>S: tasks/get taskId
S-->>H: completed + CallToolResult
| Call | Becomes |
|---|---|
| the client did not declare the extension, or the protocol is older | the classic result (unchanged) |
finished within threshold_s |
the ordinary result, no task |
still running after threshold_s |
a working task |
waits for approval (with DeferredApproval) |
a task that runs once the store holds a decision |
| waits for approval with another port | the classic approval required result |
| a resume with the client's call key | finished in the request, never a task |
the caller is anonymous on HTTP, or over max_per_principal / max_tasks |
answered without a task (a WARNING for the limits) |
TaskOptions: threshold_s=5.0 (None: only pending approvals become tasks), ttl_s=3600,
poll_interval_s=2.0, approval_url=None, max_tasks=10_000, max_per_principal=100.
Task security¶
| Rule | Effect |
|---|---|
| the approval comes only from the executor's port | the task re-reads the ApprovalStore; nothing a client sends approves |
tasks/update never approves |
answers move a URL-linked task back to working; other keys are ignored (spec) |
| a decision is used once | the rerun uses the same scoped run and call ids, so DeferredApproval checks tool and argument digest and consumes the record once |
| task ids are 256-bit random | secrets.token_urlsafe(32); no tasks/list |
| a task belongs to its principal | another caller gets -32602 Unknown or expired task, the same as a forged id |
tasks/* need the client capability |
else -32021; a Mcp-Name header that differs from taskId is refused |
| several workers | TaskOptions(store=RedisTaskStore(lambda: Redis.from_url(url))) on every worker. The worker running a task writes its state; any worker answers tasks/get, update and cancel (the last two as signals to that worker). The store only mirrors state and never approves |
| a worker dies | its open tasks read failed after 3 polls + 5 s without a store write |
| store down | other workers answer -32603 (fails closed); the owning worker still serves its tasks |
the default store=None |
tasks stay in one process: run one worker, or route tasks/* by Mcp-Name |
results sit in the store until ttl_s |
protect Redis like the approval store; the caller is kept as a SHA-256 digest |
| a server shutdown cancels running tasks | they end cancelled |
URL-mode elicitation¶
Set approval_url to the page where your people decide approvals (your own UI over the
HTTP server POST /approvals/{id}/decide). A client that
declares URL elicitation sees the task as input_required, with one elicitation/create
request in url mode pointing at that page. The link is only a pointer: the store still
decides. The URL must be https (http only on a loopback host), with {approval_id}.
Import from task servers¶
async with mcp_client(server, tasks=True, task_wait_s=0.1) as client:
tools = Registry()
await import_mcp(tools, client)
executor = Executor(tools)
ctx = executor.context(run_id="run_demo")
args = {"customer_id": "cus_1024", "amount": 49.5}
try:
await executor.run("send_invoice", args, ctx, call_id="call_demo")
except ToolError as exc: # still waiting after task_wait_s: the task id to resume
print("pending", exc.details["remote_kind"], bool(exc.details["task_id"]))
await review()
print(await executor.run("send_invoice", args, ctx, call_id="call_demo"))
| Remote task | Imported tool |
|---|---|
completed |
the result, as without tasks |
failed / cancelled |
ToolError (remote_kind task_failed / task_cancelled) |
still open after task_wait_s (default 30) |
ToolError with details["task_id"]; the same local run and call id polls that task again instead of calling twice |
input_required |
ToolError naming the task: this client answers no input requests |
OAuth (client side)¶
oauth_client(...) returns the MCP SDK's OAuthClientProvider. The SDK discovers the
protected-resource and authorization-server metadata, uses your Client ID Metadata Document
when the server supports it (else dynamic registration), runs the code flow with PKCE,
refreshes tokens, and binds stored credentials to their issuer.
document = client_metadata_document(CLIENT_ID, redirect_uris=[REDIRECT], scope="tools")
# serve json.dumps(document) at CLIENT_ID (https, a path, its own client_id)
auth = oauth_client(
"https://mcp.example.com/mcp",
client_metadata_url=CLIENT_ID,
redirect_uri=REDIRECT,
redirect_handler=open_browser,
callback_handler=wait_for_redirect,
scope="tools",
)
client = mcp_client("https://mcp.example.com/mcp", auth=auth)
# then: async with client: await import_mcp(registry, client)
| Rule | Why |
|---|---|
server_url and auth= URLs are https (http only on loopback) |
tokens never cross plain http (ConfigError) |
client_metadata_url is https with a path |
the CIMD rule; it is your client_id |
auth= with an Authorization header is a ToolUsageError |
one credential per client |
MemoryTokenStorage is the default |
per process; pass your own TokenStorage to persist; tokens are never logged |
Not built yet¶
| Feature | Status |
|---|---|
task status notifications (notifications/tasks) |
not sent: clients poll |
| a URL elicitation without tasks | not built: a plain retry has no way to name the pending call |