Task-Augmented Tool Results (SEP-1686)
A normal, synchronous tool sometimes needs to hand the caller a pointer to a
task it just kicked off — “I started the export; poll THIS task for the result.”
In MCP that pointer lives in the tool result’s _meta under
io.modelcontextprotocol/related-task. This chapter shows how to emit it the
native way (pmcp 2.12+) and how to migrate off the hand-rolled workarounds that
used to lose it silently.
This is the SEP-1686 junction. It is the complement of MCP Tasks — Long-Running Operations, which covers exposing a tool AS an async task. Here the tool returns a normal result that merely references a task.
The bug this replaces
A ToolHandler returns a serde_json::Value, and before 2.12 dispatch
UNCONDITIONALLY stringified that value into content[0].text. A handler that
hand-built a CallToolResult-shaped Value (with its own content and _meta)
had the whole object serialized into one text block — the top-level _meta
never reached the wire, so a _meta-sniffing client saw no related task.
Silently. One real incident ran that way in production for two weeks.
The native pattern
Return a real CallToolResult and attach the related task with
with_related_task. Register the tool with tool_with_result so the envelope
reaches the wire VERBATIM:
use pmcp::types::{CallToolResult, Content};
use pmcp::types::tasks::TaskMetadata;
let server = Server::builder()
.name("exporter")
.version("1.0.0")
// `store_minted_id` came from TaskStore::create — a REAL store-minted id,
// resolvable via tasks/get, NOT a hand-written literal.
.tool_with_result::<StartArgs>("start_export", move |_args, _extra| {
let task_id = store_minted_id.clone();
Box::pin(async move {
Ok(CallToolResult::new(vec![Content::text("export started")])
.with_related_task(TaskMetadata::new(task_id)))
})
})
.task_store(store)
.build()?;
On the client, detection is one accessor — no manual _meta parsing:
let result = client.call_tool("start_export".into(), json!({})).await?;
if let Some(meta) = result.related_task() {
// Poll to terminal, then fetch the result — SDK-owned, wasm-safe.
let task_result = client.wait_for_related_task(&meta, Default::default()).await?;
}
Note:
wait_for_task/wait_for_related_taskreturn an error if the task entersinput_required— that state is not terminal and needs client-side action (elicitation) the poller cannot provide. Handle the required input, then resume polling.
Migrating a hand-written handler
If you have a hand-written ToolHandler, override handle_output to return
ToolOutput::Result. handle() stays as a serialize fallback; the default
handle_output delegates to it, so nothing else changes:
async fn handle_output(&self, args: Value, extra: RequestHandlerExtra)
-> pmcp::Result<pmcp::ToolOutput>
{
Ok(pmcp::ToolOutput::Result(
CallToolResult::new(vec![Content::text("done")])
.with_related_task(TaskMetadata::new(task_id)),
))
}
If the handler is otherwise happy on the normal path and just needs to ADD
_meta, one call retrofits it — no handle_output impl required:
extra.set_result_meta(related_task_meta_map); // merges, handler-key-wins
Wire-compat: existing _meta-sniffing clients keep working
The native task create-path already emits _meta[related-task] with the
store-minted id, proven against real dispatch output in
src/server/core_tests.rs. A durable client that reads
result._meta["io.modelcontextprotocol/related-task"] detects a native
with_task_store() server UNCHANGED — no client migration required. The SDK
client also WARNs (never silently swallows) on a malformed task payload.
Try it
The runnable BEFORE/AFTER migration lives in
examples/s47_task_augmented_result.rs:
cargo run --example s47_task_augmented_result --features full
It registers the BEFORE tool with suppress_double_wrap_check ONLY so the broken
shape can be demonstrated without aborting on the debug-build tripwire — real
tools should migrate, not suppress. The wire shape is locked in CI by
tests/tool_output_result_http.rs over a real HTTP round-trip.
⚠️ A
tool_with_result/ToolOutput::Resulttool bypasses RESPONSE middleware. Redaction, sanitization, and audit hooks (ToolMiddlewareon_response) DO NOT run for a verbatim result — the handler owns its OWN redaction of bothcontentand_meta, at the same trust level as returning a rawValue. This is a deliberate, locked design decision (D-04a): “keep the bypass, harden it.” Request middleware still runs, and handler errors still route through the normal error path. Redact inside the handler BEFORE building theCallToolResult. Seedocs/design/sep-1686-task-augmented-results.mdfor the full rationale.