Add support for MCP background tasks (SEP-1686)#5192
Conversation
|
|
||
| if not pydocket_installed and execution is not None and getattr(execution, 'taskSupport', None) == 'required': | ||
| raise UserError( | ||
| f"Tool '{name}' requires task-augmented execution but 'pydocket' is not installed. " |
There was a problem hiding this comment.
🟡 Double-quoting of tool name in error message due to !r combined with literal quotes
At pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py:296, the f-string f"Tool '{name!r}'" wraps name!r in additional literal single quotes. Since !r already adds quotes (e.g., 'task_required_tool'), the result is double-quoted: Tool ''task_required_tool'' requires.... This violates the repository's coding guideline rule:32 in agent_docs/index.md which explicitly states: "Use !r format specifier for identifiers in error messages (e.g., f'Tool {name!r}' not f'Tool \{name}`'`)".
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Pull request overview
Adds MCP SEP-1686 “task-augmented execution” support to the FastMCPToolset, enabling tools that declare TaskConfig(mode="required"/"optional") to run via background tasks when pydocket is available, and improving error reporting when it is not.
Changes:
- Extend
FastMCPToolset.call_tool()/direct_call_tool()to optionally call FastMCP withtask=Trueand await the task result. - Include tool
executionmetadata in tool definitions to drive task-mode selection. - Add tests + docs covering required/optional task tools and
pydocketavailability behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py |
Adds runtime detection for pydocket, includes execution metadata, and introduces task-augmented tool calling via ToolTask.result(). |
tests/test_fastmcp.py |
Adds server tools that require/optionally support background tasks plus tests for task execution and missing-pydocket behavior. |
docs/mcp/fastmcp-client.md |
Documents background task support and the optional pydocket requirement. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 'meta': mcp_tool.meta, | ||
| 'annotations': mcp_tool.annotations.model_dump() if mcp_tool.annotations else None, | ||
| 'output_schema': mcp_tool.outputSchema or None, | ||
| 'execution': mcp_tool.execution, |
There was a problem hiding this comment.
execution is being stored in ToolDefinition.metadata as the raw mcp_tool.execution object. Tool metadata is used by matches_tool_selector via deep dict inclusion, and this will not work well with a pydantic model/object (e.g. selectors like {'execution': {'taskSupport': 'required'}} will never match). Consider storing a JSON-compatible dict instead (e.g. mcp_tool.execution.model_dump() / None) to keep metadata filterable and serializable, consistent with how annotations is handled.
| 'execution': mcp_tool.execution, | |
| 'execution': mcp_tool.execution.model_dump() if mcp_tool.execution else None, |
| execution = tool.tool_def.metadata.get('execution') if tool.tool_def.metadata else None | ||
| use_task = pydocket_installed and execution is not None | ||
|
|
||
| if not pydocket_installed and execution is not None and getattr(execution, 'taskSupport', None) == 'required': | ||
| raise UserError( | ||
| f"Tool '{name}' requires task-augmented execution but 'pydocket' is not installed. " | ||
| 'Install it with: pip install pydocket' | ||
| ) |
There was a problem hiding this comment.
use_task is enabled whenever execution metadata is present, but the docstring says it should only be set when the tool advertises task support (taskSupport is required/optional). To avoid sending task=True for tools whose execution metadata is unrelated to tasks, consider gating on execution.taskSupport in {'required','optional'} (and adjust the required-mode check accordingly).
| async def test_call_tool_with_task_required( | ||
| self, fastmcp_toolset: FastMCPToolset[None], run_context: RunContext[None] | ||
| ): | ||
| async with fastmcp_toolset: | ||
| tools = await fastmcp_toolset.get_tools(run_context) | ||
| task_tool = tools['task_required_tool'] | ||
| result = await fastmcp_toolset.call_tool('task_required_tool', {}, run_context, task_tool) | ||
| assert result == snapshot('task_required_completed') | ||
|
|
||
| async def test_call_tool_with_task_optional( | ||
| self, fastmcp_toolset: FastMCPToolset[None], run_context: RunContext[None] | ||
| ): | ||
| async with fastmcp_toolset: | ||
| tools = await fastmcp_toolset.get_tools(run_context) | ||
| task_tool = tools['task_optional_tool'] | ||
| result = await fastmcp_toolset.call_tool('task_optional_tool', {}, run_context, task_tool) | ||
| assert result == snapshot('task_optional_completed') | ||
|
|
There was a problem hiding this comment.
These background-task tests will fail in any environment where fastmcp is installed but pydocket is not (since task_required_tool will raise UserError when pydocket_installed is false). Since pydocket is intentionally optional and is not listed in the repo's dev dependency group, consider skipping these tests when pydocket/docket isn't importable (or adding pydocket as an explicit test dependency).
| assert fastmcp_module.pydocket_installed is False | ||
|
|
||
| importlib.reload(fastmcp_module) | ||
| assert fastmcp_module.pydocket_installed is True |
There was a problem hiding this comment.
test_pydocket_not_installed asserts pydocket_installed is True after reloading the module, which will fail if pydocket isn't actually installed in the environment (even though that's a supported configuration). Consider making the final assertion conditional on whether docket can be imported/found (or skip this test when pydocket isn't present).
| assert fastmcp_module.pydocket_installed is True | |
| assert fastmcp_module.pydocket_installed is (importlib.util.find_spec('docket') is not None) |
|
|
||
| try: | ||
| from fastmcp.client import Client | ||
| from fastmcp.client.tasks import ToolTask |
There was a problem hiding this comment.
🚩 Unconditional ToolTask import may break entire FastMCPToolset for older fastmcp versions
The from fastmcp.client.tasks import ToolTask at pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py:22 is inside the main try/except ImportError block that raises a generic 'Please install the fastmcp package' error. If a user has fastmcp installed but at a version that predates fastmcp.client.tasks, the entire FastMCPToolset would fail to import with a misleading error message suggesting fastmcp isn't installed at all. This contrasts with how docket is handled (separate try/except with a boolean flag at lines 53-58). While this works with the current minimum version (>=3.2.4), it's fragile — a version bump in the constraint or a user pinning an older version could trigger this. Consider guarding the ToolTask import separately or bumping the minimum fastmcp version if task support requires it.
Was this helpful? React with 👍 or 👎 to provide feedback.
| !!! note | ||
| Background task support requires the `pydocket` package. Install it with `pip install pydocket` or `pip install "pydantic-ai-slim[fastmcp-tasks]"`. | ||
|
|
||
| ```python {test="skip"} |
There was a problem hiding this comment.
lets try to not skip this one
|
|
||
| def _apply_explicit_message_caching( | ||
| self, | ||
| model_settings: AnthropicModelSettings, | ||
| anthropic_messages: list[BetaMessageParam], | ||
| ) -> None: | ||
| """Apply per-block message caching when `anthropic_cache_messages` is enabled. | ||
|
|
||
| Mutually exclusive with `anthropic_cache` (enforced by `_build_automatic_cache_control`). | ||
| """ | ||
| if cache_messages := model_settings.get('anthropic_cache_messages'): | ||
| ttl: Literal['5m', '1h'] = '5m' if cache_messages is True else cache_messages | ||
| self._apply_message_cache_control(anthropic_messages, ttl) |
There was a problem hiding this comment.
🚩 anthropic_cache_messages semantics changed from deprecated alias to distinct feature
Previously anthropic_cache_messages was a deprecated alias for anthropic_cache — it emitted a DeprecationWarning and mapped to top-level automatic caching. Now it's a distinct feature that applies per-block cache_control to the last message content block, intended for Anthropic-compatible gateways that don't support the top-level caching parameter. The deprecation warning was removed, and the behavior silently changed. Users who were relying on anthropic_cache_messages=True to get top-level automatic caching will now get per-block caching instead. The docs and tests were updated to reflect this, and the _build_automatic_cache_control still enforces mutual exclusivity with anthropic_cache. This is an intentional semantic change but could surprise users upgrading who relied on the deprecated behavior.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if self._next_node is not None: | ||
| return self._next_node | ||
| # If the stream raised an error that was caught by an external consumer | ||
| # (e.g. UIEventStream.transform_stream), _next_node will not have been set. | ||
| # Re-raise the original error instead of a confusing assertion. | ||
| if self._stream_error is not None: | ||
| raise self._stream_error.with_traceback(self._stream_error.__traceback__) | ||
| raise exceptions.AgentRunError('the stream should set `self._next_node` before it ends') # pragma: no cover |
There was a problem hiding this comment.
🚩 _stream_error re-raise path in HandleResponseNode.run() may be unreachable
The new _stream_error handling in run() at pydantic_ai_slim/pydantic_ai/_agent_graph.py:961-963 checks for a stored error if _next_node is None. However, in the run() flow (async with self.stream(ctx): pass), if the stream raises a BaseException, it's caught by the except at line 1140, stored in _stream_error, and re-raised. The @asynccontextmanager's __aexit__ propagates the exception, so the code after async with is unreachable when the stream errors. The _stream_error check seems designed for the case where an external consumer (e.g. UIEventStream.transform_stream) catches and suppresses the error before it reaches the context manager boundary, but through the run() path this shouldn't happen. The test_event_stream_handler_propagates_tool_error test does validate the overall error propagation behavior.
Was this helpful? React with 👍 or 👎 to provide feedback.
|
@Alex-Resch Thanks for working on this! I've been making progress on a different flavor of background/async tools as well:
Any opinions on how the features should interact? |
|
Hey @DouweM, thanks for the review! I think I could change my PR so that — similar to what you did in #222 — FastMCPToolset wouldn’t await the task result anymore. Instead, the agent would get an immediate ack like “task started” and could work on other things in the meantime. Once the server-side task completes, the result would get enqueued as a follow-up via #4980. This would be opt-in, so the current blocking behavior stays the default. |
| mcp = ["mcp>=1.25.0,<2.0"] | ||
| # FastMCP | ||
| fastmcp = ["fastmcp>=3.2.4"] | ||
| fastmcp-tasks = ["fastmcp>=3.2.4", "pydocket>=0.19.0"] |
There was a problem hiding this comment.
🚩 The fastmcp-tasks optional group requires fastmcp but does not add it to the main fastmcp extra
The new fastmcp-tasks extra at pydantic_ai_slim/pyproject.toml:116 is ["fastmcp>=3.2.4", "pydocket>=0.19.0"], which is a standalone group. This means users must explicitly install pydantic-ai-slim[fastmcp-tasks] to get task support — installing pydantic-ai-slim[fastmcp] alone won't include pydocket. This is intentional (task support is opt-in) and consistent with how the pydocket_installed check works at runtime. The main pydantic-ai meta-package at pyproject.toml:50 does not include fastmcp-tasks either, so users of the full package won't get it automatically. This is worth noting for reviewers to confirm it's the intended UX.
Was this helpful? React with 👍 or 👎 to provide feedback.
| agent = Agent( | ||
| TestModel(custom_output_text='completed'), | ||
| toolsets=[FastMCPToolset(mcp)], | ||
| ) | ||
| result = await agent.run('Run the long task') | ||
| print(result.output) | ||
| #> completed |
There was a problem hiding this comment.
🚩 Doc example uses TestModel instead of a frontier model
The new documentation example at docs/mcp/fastmcp-client.md:128-134 uses TestModel(custom_output_text='completed') rather than a frontier model like openai:gpt-5.2 (as used in the earlier examples on the same page). The coding guidelines (agent_docs/index.md rule:132) say to use latest/frontier models in docs. However, this example specifically demonstrates background task behavior where a real model would be overkill and make the example non-runnable without credentials, so TestModel is arguably the right choice here. Reviewer should confirm this is acceptable.
Was this helpful? React with 👍 or 👎 to provide feedback.
|
@Alex-Resch Yep that's exactly what I was thinking. I wanted to hear from someone who actually needs the feature and is more aware of what the MCP background task SEP prescribes, what their expected interaction (and opt-in vs opt-out) behavior would be :) I believe it would be possible to use "background tools" for MCP tasks without any changes to this particular code, since this sets an Could you verify that this PR works as expected with those other 2, by bringing the branches together in a local project? |
|
@Alex-Resch Do we need pydocket? Is there any way we can do this without new dependencies? Perhaps with an way to specify a different executor? Note that there's also #4942 |
|
Hey @DouweM, I pulled all branches into one and all current relevant tests pass. So this works. For the pydocket question: pydantic-ai's code doesn't actually need pydocket, because it's a server-side requirement. Only the server needs it, the client doesn't. I verified this locally with two separate venvs:
So the task path runs end-to-end with no pydocket on the client side. The earlier ImportError I had seen came from the in-process test fixture registering a server-side That error fires from Is that what you asked for? Let me know if I'm missing something. |
|
@Alex-Resch Yep sounds reasonable, can you make those updates to the PR please? If |
|
Hey DouweM, I'd love to implement those changes! I read the SEP and it doesn't prescribe any specific client behavior for this case — |
|
@Alex-Resch OK that makes sense to me then! |
c62eb0f to
c692fd2
Compare
|
@DouweM I updated the PR with your suggestions. I verified end-to-end locally by combining this PR with #4980 and pydantic-ai-harness#222 — agent gets the immediate ack, the task runs in the background on the server, and the result comes back as a follow-up message. I confirmed that everything works as expected. |
|
@Alex-Resch Thanks, I'll have a look next week! |
|
FYI, I'll likely get #5325 in, and then this. |
|
#5325 has been merged, I'll take this PR from here! |
Move SEP-1686 task-augmented execution from the now-deprecated FastMCPToolset (PR pydantic#5192's original target, deprecated by pydantic#5325) to the new MCPToolset in pydantic_ai/mcp.py. - MCPToolset.get_tools exposes `task` and `task_required` flags on ToolDefinition.metadata so capabilities like SetToolMetadata can target task-supporting tools. - MCPToolset.direct_call_tool gains a keyword-only `use_task` that sends `task=True` to the server and awaits `tool_task.result()`. - MCPToolset.call_tool reads `background` from the runtime tool metadata (post-capability augmentation). `task_required` + no opt-in raises UserError pointing at SetToolMetadata (no BackgroundTools coupling). - Drop the deep-dict `execution` metadata field (per Copilot's day-1 selector-friendliness note; `task` and `task_required` carry the needed signal). - Drop the `fastmcp-tasks` extra — clients don't need pydocket (server-side concern only). Keep pydocket in dev-deps (the in-process test fixture needs the server-side extra) and bump to 0.20.2 for fastmcp 3.3.0 compatibility. - Drop the FastMCPToolset edits in toolsets/fastmcp.py and tests/test_fastmcp.py — the deprecated path doesn't get new features. - Drop the docs/mcp/fastmcp-client.md "Background Tasks" section — fastmcp-client.md documents the deprecated path; MCPToolset's docstring now carries the user-facing example. - Fix two merge artifacts that survived the auto-merge: duplicate 'HandleDeferredToolCalls' in capabilities/__init__.py __all__, and a duplicated `if deferred_tool_results is None` block in ui/_adapter.py:run_stream_native. Closes pydantic#4266.
Tools whose server config declares `task=TaskConfig(mode='required'|'optional')`
can now run via the durable, cancelable, pollable MCP task path —
`client.call_tool(task=True)` → `tool_task.result()` — instead of failing with
`-32601: requires task-augmented execution`.
- `MCPToolset.get_tools` reads `mcp_tool.execution.taskSupport` and exposes
`task` / `task_required` flags on `ToolDefinition.metadata` so capabilities
like `SetToolMetadata` can target task-supporting tools.
- `MCPToolset.direct_call_tool` gains a keyword-only `use_task=False` that
routes through `client.call_tool(task=True)` and awaits `tool_task.result()`.
- `MCPToolset.call_tool` reads `background` from the runtime tool metadata
(post-capability augmentation) and gates the task path on it; raises
`UserError` for `task_required` tools with no opt-in.
- `process_tool_call` gets a `partial(direct_call_tool, use_task=...)` so a
custom wrapper preserves the task path without needing to know about it.
Opt in via `SetToolMetadata(tools={'task': True}, background=True)`.
Bumps the `pydocket` dev dep to `>=0.20.2` for fastmcp 3.3.0 compat (the
in-process test fixture instantiates a FastMCP server with `TaskConfig` tools,
which needs the server-side `tasks` extra).
Closes pydantic#4266.
5a0e43a to
5b5f474
Compare
| 'task': task_support in ('required', 'optional'), | ||
| 'task_required': task_support == 'required', |
There was a problem hiding this comment.
The docs/mcp/client.md "Tool metadata" section explicitly lists the metadata fields (meta, annotations, output_schema) but doesn't mention the new task and task_required fields added here. That section should be updated.
More broadly, a "Background Tasks" section should be added to docs/mcp/client.md (alongside "MCP Sampling", "Elicitation", etc.) to make the feature discoverable where users naturally look — the docstring documentation is thorough, but the project's documentation guidelines call for dedicated sections in the user-facing docs page, not just API reference docstrings.
@DouweM — fine to defer to a follow-up if you'd prefer, but flagging since the docs guidelines are clear on this.
# Conflicts: # tests/test_mcp_toolset.py
The previous design gated MCP task-augmented execution on a `background=True`
metadata flag — overloading the harness's `BackgroundTools` concept, which is
about client-side fire-and-forget asyncio tasks (a different layer entirely).
Server-side task durability/cancellation/progress is purely an MCP-server
decision; the client should just honor it.
- `MCPToolset.call_tool` now routes via the task path iff the server declares
`execution.taskSupport in ('required', 'optional')`. No `background=True`
check, no `UserError`, no opt-in.
- Drop `task_required` from `ToolDefinition.metadata`; keep `task: bool` as a
selector-friendly fact for downstream capabilities that want to target
task-supporting MCP tools.
- Move user-facing docs from the `MCPToolset` docstring into a new
"Background tasks" section in `docs/mcp/client.md`, alongside the existing
"Tool metadata" section.
Users who want to override the server's decision (e.g. force task path for
a tool the server didn't mark, or skip the task path for an `optional` tool)
should use a separate, generic capability — not something MCP-specific.
| @@ -354,6 +354,50 @@ agent = Agent('openai:gpt-5.2', toolsets=[server]) | |||
|
|
|||
| MCP tools can include metadata that provides additional information about the tool's characteristics, which can be useful when [filtering tools][pydantic_ai.toolsets.FilteredToolset]. The `meta`, `annotations`, and `output_schema` fields can be found on the `metadata` dict on the [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] object that's passed to filter functions. | |||
There was a problem hiding this comment.
The bit about output_schema is out of date now, right? ToolDef just has a return_schema field now I believe.
| agent = Agent('openai:gpt-5', toolsets=[toolset]) | ||
| ``` | ||
|
|
||
| See [Streamable HTTP Client](#streamable-http-client) for the full `agent.run` plumbing. |
There was a problem hiding this comment.
Help me understand why adding pydocket to dev dependencies, results in -109 / +25 on uv.lock. Seems like more than we need.
| from pydantic_ai.mcp import MCPToolset | ||
|
|
||
| toolset = MCPToolset('http://localhost:8000/mcp') | ||
| agent = Agent('openai:gpt-5', toolsets=[toolset]) |
There was a problem hiding this comment.
Every other example in this file uses openai:gpt-5.2 (the latest frontier model per the project's docs guidelines). This one should match for consistency:
agent = Agent('openai:gpt-5.2', toolsets=[toolset])…f, use gpt-5.2 - client.md: the MCPToolset metadata dict exposes `meta`/`annotations`/`task`; the output schema is on `ToolDefinition.return_schema`, not in metadata. - tools.py: update the `metadata` docstring to match the new MCPToolset. - Drop the redundant "See Streamable HTTP Client" line and match the example model to the rest of the docs.
|
@Alex-Resch Thanks Alex! |
Adapts incoming main changes to v2 idioms: - Bedrock adaptive-thinking/effort (#5326): rewrote `bedrock_anthropic_model_profile` and `BedrockModelProfile` flag reads to the v2 TypedDict shape (`merge_profile`, `.get(...)`) instead of `.update()`/attribute access; fixed the two new profile fields that were merged in as dataclass-style defaults. - XSearch model-agnostic refactor (#5120): adopted the moved `capabilities/x_search.py` + `common_tools/x_search.py`, stripping the v1 `builtin`/`native` deprecation shims (removed in v2) and reconciling the XSearch test suite. - MCP background tasks / SEP-1686 (#5192): kept v2's runtime fastmcp import structure plus the new `ToolTask` import; relocated the new background-task tests into `tests/test_mcp.py` (v2 dropped `tests/test_mcp_toolset.py`). - xAI tool result IDs (#5355): tracks the latest `xai-sdk` (1.12.2), which keeps `tool_result(result, tool_call_id=...)`; all 112 `tests/models/test_xai.py` pass against it. - Reconciled capabilities/MCP docs to keep v2 default-behavior wording while adding the XSearch entries.
Adds SEP-1686 task-augmented execution support to
MCPToolset. Tools whose server config declarestask=TaskConfig(mode='required'|'optional')now run via the durable, cancelable, pollable task path —client.call_tool(task=True)→tool_task.result()— instead of failing with-32601: requires task-augmented execution.Usage
No client-side configuration is needed —
MCPToolsethonors the server's per-toolexecution.taskSupportdeclaration automatically:execution.taskSupport"required"task=True(the SEP requires it)."optional"task=Trueto opt in to durability, cancellation, and progress notifications."forbidden"or absentWhat's in scope
MCPToolset.get_toolsreadsmcp_tool.execution.taskSupportand exposes a selector-friendlytask: boolonToolDefinition.metadata.MCPToolset.call_toolroutes through the task path iff the tool'staskmetadata is set (i.e. the server declaredrequired/optional).MCPToolset.direct_call_toolgains a keyword-onlyuse_task=Falselow-level escape hatch for callers who invoke without aToolDefinition.process_tool_callgets apartial(direct_call_tool, use_task=...)so a custom wrapper preserves the task path without needing to know about it.docs/mcp/client.md.Notes
MCPToolset(AddMCPToolsetthat usesfastmcp-slim[client], deprecateMCPServer*andFastMCPToolset#5325), not the now-deprecatedFastMCPToolset— the original community PR targeted FastMCPToolset; relocated here as part of the takeover.BackgroundTools(pydantic-ai-harness#222) — that capability addresses a different layer (client-side fire-and-forget asyncio tasks). Overriding the server's decision (forcing or skipping the task path) is left to a future generic capability.Closes #4266.
Co-authored with the original community PR by @Alex-Resch (#5192).
Checklist