From 39e44fa8bdad8754ab6afa17969b81c011325aeb Mon Sep 17 00:00:00 2001 From: Alex Resch Date: Fri, 15 May 2026 23:11:29 +0000 Subject: [PATCH 1/4] MCPToolset: add support for MCP background tasks (SEP-1686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #4266. --- pydantic_ai_slim/pydantic_ai/mcp.py | 49 +++++++-- pyproject.toml | 1 + tests/test_mcp_toolset.py | 151 +++++++++++++++++++++++++++- uv.lock | 134 +++++------------------- 4 files changed, 216 insertions(+), 119 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 5d81f4e708..f28f39eed9 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -53,6 +53,7 @@ from fastmcp.client.progress import ProgressHandler from fastmcp.client.roots import RootsHandler, RootsList from fastmcp.client.sampling import SamplingHandler + from fastmcp.client.tasks import ToolTask from fastmcp.client.transports import ( ClientTransport, SSETransport, @@ -2084,8 +2085,10 @@ async def list_tools(self) -> list[mcp_types.Tool]: async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]: max_retries = self.max_retries if self.max_retries is not None else ctx.max_retries - return { - mcp_tool.name: ToolsetTool[AgentDepsT]( + tools: dict[str, ToolsetTool[AgentDepsT]] = {} + for mcp_tool in await self.list_tools(): + task_support = mcp_tool.execution.taskSupport if mcp_tool.execution else None + tools[mcp_tool.name] = ToolsetTool[AgentDepsT]( toolset=self, tool_def=ToolDefinition( name=mcp_tool.name, @@ -2094,6 +2097,8 @@ async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[ metadata={ 'meta': mcp_tool.meta, 'annotations': mcp_tool.annotations.model_dump() if mcp_tool.annotations else None, + 'task': task_support in ('required', 'optional'), + 'task_required': task_support == 'required', }, return_schema=mcp_tool.outputSchema or None, include_return_schema=self.include_return_schema, @@ -2101,8 +2106,7 @@ async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[ max_retries=max_retries, args_validator=TOOL_SCHEMA_VALIDATOR, ) - for mcp_tool in await self.list_tools() - } + return tools def tool_for_tool_def(self, tool_def: ToolDefinition) -> ToolsetTool[AgentDepsT]: return ToolsetTool[AgentDepsT]( @@ -2118,6 +2122,7 @@ async def direct_call_tool( args: dict[str, Any], *, metadata: dict[str, Any] | None = None, + use_task: bool = False, ) -> Any: """Call a tool on the server directly. @@ -2125,6 +2130,10 @@ async def direct_call_tool( name: The name of the tool to call. args: The arguments to pass to the tool. metadata: Optional request-level `_meta` payload sent alongside the call. + use_task: When `True`, send the call with `task=True` per MCP + [SEP-1686](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) so + the server wraps execution in a durable, cancelable, pollable task; the result is awaited via + `tasks/result`. Only valid for tools whose `execution.taskSupport` is `'required'` or `'optional'`. Raises: ModelRetry: If the tool errors and `tool_error_behavior='retry'` (the default). @@ -2132,7 +2141,13 @@ async def direct_call_tool( """ async with self: try: - result: CallToolResult = await self.client.call_tool(name=name, arguments=args, meta=metadata) + if use_task: + tool_task: ToolTask = await self.client.call_tool( + name=name, arguments=args, task=True, meta=metadata + ) + result: CallToolResult = await tool_task.result() + else: + result = await self.client.call_tool(name=name, arguments=args, meta=metadata) except ToolError as e: if self.tool_error_behavior == 'retry': raise exceptions.ModelRetry(message=str(e)) from e @@ -2159,9 +2174,29 @@ async def call_tool( ctx: RunContext[Any], tool: ToolsetTool[Any], ) -> Any: + metadata = tool.tool_def.metadata or {} + supports_task = bool(metadata.get('task')) + task_required = bool(metadata.get('task_required')) + + # Whether to opt in to the MCP task path is read from the *runtime* tool metadata + # (post-capability augmentation, e.g. by `SetToolMetadata`), not the static toolset view. + runtime_tool = ctx.tool_manager.tools.get(name) if ctx.tool_manager and ctx.tool_manager.tools else None + background = bool((runtime_tool.tool_def.metadata or {}).get('background')) if runtime_tool else False + + if task_required and not background: + raise exceptions.UserError( + f'Tool {name!r} requires MCP task-augmented execution but no capability has opted it in. ' + f"Add `SetToolMetadata(tools={{'task': True}}, background=True)` to your agent's capabilities, " + f"or any equivalent that sets `background=True` on the tool's metadata." + ) + + use_task = supports_task and background + if self.process_tool_call is not None: - return await self.process_tool_call(ctx, self.direct_call_tool, name, tool_args) - return await self.direct_call_tool(name, tool_args) + return await self.process_tool_call( + ctx, functools.partial(self.direct_call_tool, use_task=use_task), name, tool_args + ) + return await self.direct_call_tool(name, tool_args, use_task=use_task) async def list_resources(self) -> list[Resource]: """Retrieve the resources currently exposed by the server. diff --git a/pyproject.toml b/pyproject.toml index 00cd260ea5..bc66899502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ dev = [ "coverage[toml]>=7.10.7", "dirty-equals>=0.9.0", "duckduckgo-search>=7.0.0", + "pydocket>=0.20.2", "exa-py>=2.0.0", "tavily-python>=0.5.0", "markdownify>=1.2", diff --git a/tests/test_mcp_toolset.py b/tests/test_mcp_toolset.py index d1f39eaa1e..34e7b89774 100644 --- a/tests/test_mcp_toolset.py +++ b/tests/test_mcp_toolset.py @@ -11,7 +11,9 @@ class is validated against the same surface area as the legacy `FastMCPToolset`. from __future__ import annotations +import asyncio import base64 +import dataclasses import json from pathlib import Path from tempfile import TemporaryDirectory @@ -22,8 +24,10 @@ class is validated against the same surface area as the legacy `FastMCPToolset`. from pydantic_ai import models from pydantic_ai._run_context import RunContext -from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.exceptions import ModelRetry, UserError from pydantic_ai.models.test import TestModel +from pydantic_ai.tool_manager import ToolManager +from pydantic_ai.tools import ToolDefinition from pydantic_ai.usage import RunUsage from .conftest import try_import @@ -36,6 +40,7 @@ class is validated against the same surface area as the legacy `FastMCPToolset`. ) from fastmcp.exceptions import ToolError from fastmcp.server import FastMCP + from fastmcp.server.tasks import TaskConfig from mcp import types as mcp_types from mcp.types import ( BlobResourceContents, @@ -559,8 +564,6 @@ async def test_label_falls_back_to_repr(self): assert 'MCPToolset' in toolset.label async def test_tool_for_tool_def_uses_default_retries_when_unset(self): - from pydantic_ai.tools import ToolDefinition - toolset = MCPToolset('https://example.com/mcp') tool = toolset.tool_for_tool_def( ToolDefinition(name='foo', description='', parameters_json_schema={'type': 'object'}) @@ -721,6 +724,148 @@ async def test_load_mcp_toolsets_http_entry(self): assert wrapped.client.transport.headers == {'X-Key': 'foo'} +class TestMCPToolsetBackgroundTasks: + """SEP-1686 task-augmented execution. Tools whose server config declares + `task=TaskConfig(mode='required'|'optional')` opt into a durable, cancelable, pollable execution path — + `client.call_tool(task=True)` → `tool_task.result()`. + + The client-side opt-in is a `background=True` flag on the tool's runtime metadata, set by any capability + (e.g. `SetToolMetadata(tools={'task': True}, background=True)`). Without the opt-in, `mode='required'` + raises `UserError` and `mode='optional'` falls back to the regular sync path.""" + + @pytest.fixture + async def task_server(self) -> FastMCP[None]: + server: FastMCP[None] = FastMCP('task_server') + + @server.tool(task=TaskConfig(mode='required')) + async def task_required_tool() -> str: + """A tool that requires task-augmented execution.""" + await asyncio.sleep(0) + return 'task_required_completed' + + @server.tool(task=TaskConfig(mode='optional')) + async def task_optional_tool() -> str: + """A tool that may run either as a task or synchronously.""" + await asyncio.sleep(0) + return 'task_optional_completed' + + return server + + async def test_get_tools_exposes_task_metadata( + self, task_server: FastMCP[None], run_context: RunContext[None] + ) -> None: + """`get_tools` exposes `task` (any task support) and `task_required` so capabilities like + `SetToolMetadata(tools={'task': True}, background=True)` can target task-augmented tools.""" + toolset = MCPToolset(task_server) + async with toolset: + tools = await toolset.get_tools(run_context) + + required_meta = tools['task_required_tool'].tool_def.metadata or {} + optional_meta = tools['task_optional_tool'].tool_def.metadata or {} + assert required_meta['task'] is True + assert required_meta['task_required'] is True + assert optional_meta['task'] is True + assert optional_meta['task_required'] is False + + async def test_required_tool_without_opt_in_raises( + self, task_server: FastMCP[None], run_context: RunContext[None] + ) -> None: + """`mode='required'` without `background=True` in the runtime metadata raises `UserError` before + any RPC — the call would otherwise fail with a server-side `Method not found`.""" + toolset = MCPToolset(task_server) + async with toolset: + tools = await toolset.get_tools(run_context) + ctx = _ctx_with_runtime_tools(run_context, toolset, tools) + with pytest.raises(UserError, match='requires MCP task-augmented execution'): + await toolset.call_tool('task_required_tool', {}, ctx, tools['task_required_tool']) + + async def test_optional_tool_without_opt_in_uses_sync_path( + self, task_server: FastMCP[None], run_context: RunContext[None] + ) -> None: + """`mode='optional'` without an opt-in falls back to the regular sync call — no error, real result.""" + toolset = MCPToolset(task_server) + async with toolset: + tools = await toolset.get_tools(run_context) + ctx = _ctx_with_runtime_tools(run_context, toolset, tools) + result = await toolset.call_tool('task_optional_tool', {}, ctx, tools['task_optional_tool']) + assert result == 'task_optional_completed' + + async def test_required_tool_with_opt_in_uses_task_path( + self, task_server: FastMCP[None], run_context: RunContext[None] + ) -> None: + """With `background=True` on the runtime tool, a `mode='required'` tool succeeds via the task path — + getting back the real result proves `task=True` was sent (the server would otherwise return + `-32601: requires task-augmented execution`).""" + toolset = MCPToolset(task_server) + async with toolset: + tools = await toolset.get_tools(run_context) + ctx = _ctx_with_runtime_tools(run_context, toolset, tools, opted_in={'task_required_tool'}) + result = await toolset.call_tool('task_required_tool', {}, ctx, tools['task_required_tool']) + assert result == 'task_required_completed' + + async def test_optional_tool_with_opt_in_uses_task_path( + self, task_server: FastMCP[None], run_context: RunContext[None] + ) -> None: + """`mode='optional'` + `background=True` opts into the task path.""" + toolset = MCPToolset(task_server) + async with toolset: + tools = await toolset.get_tools(run_context) + ctx = _ctx_with_runtime_tools(run_context, toolset, tools, opted_in={'task_optional_tool'}) + result = await toolset.call_tool('task_optional_tool', {}, ctx, tools['task_optional_tool']) + assert result == 'task_optional_completed' + + async def test_direct_call_tool_with_use_task(self, task_server: FastMCP[None]) -> None: + """`direct_call_tool(..., use_task=True)` takes the task path — `mode='required'` works without + the higher-level opt-in plumbing, since users calling `direct_call_tool` already know what they want.""" + toolset = MCPToolset(task_server) + async with toolset: + result = await toolset.direct_call_tool('task_required_tool', {}, use_task=True) + assert result == 'task_required_completed' + + async def test_process_tool_call_receives_use_task_partial( + self, task_server: FastMCP[None], run_context: RunContext[None] + ) -> None: + """`process_tool_call` gets a `CallToolFunc` that already has `use_task` baked in via `partial`, + so a custom wrapper doesn't need to know about the task path to preserve it.""" + + async def passthrough(ctx: RunContext[Any], call_tool: Any, name: str, args: dict[str, Any]) -> Any: + return await call_tool(name, args) + + toolset = MCPToolset(task_server, process_tool_call=passthrough) + async with toolset: + tools = await toolset.get_tools(run_context) + ctx = _ctx_with_runtime_tools(run_context, toolset, tools, opted_in={'task_required_tool'}) + result = await toolset.call_tool('task_required_tool', {}, ctx, tools['task_required_tool']) + assert result == 'task_required_completed' + + +def _ctx_with_runtime_tools( + ctx: RunContext[None], + toolset: MCPToolset[None], + tools: dict[str, Any], + *, + opted_in: set[str] | None = None, +) -> RunContext[None]: + """Build a `RunContext` whose `tool_manager.tools` mirrors `tools` but adds `background=True` to the + runtime metadata for tools in `opted_in` — emulating what `SetToolMetadata(tools={'task': True}, + background=True)` would do at runtime.""" + opted_in = opted_in or set() + runtime_tools = { + name: dataclasses.replace( + tool, + tool_def=dataclasses.replace( + tool.tool_def, + metadata={**(tool.tool_def.metadata or {}), 'background': True}, + ), + ) + if name in opted_in + else tool + for name, tool in tools.items() + } + tool_manager: ToolManager[None] = ToolManager(toolset=toolset, tools=runtime_tools) + return dataclasses.replace(ctx, tool_manager=tool_manager) + + def test_construction_does_not_emit_warnings(recwarn: Any) -> None: """Building an `MCPToolset` from a URL must not emit `FastMCPDeprecationWarning` for the `sse_read_timeout` parameter — the StreamableHttp path migrated off it (the FastMCP `Client` diff --git a/uv.lock b/uv.lock index c90ab830bf..83e5a302ea 100644 --- a/uv.lock +++ b/uv.lock @@ -913,6 +913,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/d3/b07f8f125ac52bbee5dc00ef0d526f820f67321bf4184f915f17f50a4657/brotlicffi-1.2.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3832c66e00d6d82087f20a972b2fc03e21cd99ef22705225a6f8f418a9158ecc", size = 374730, upload-time = "2025-11-21T18:17:56.334Z" }, ] +[[package]] +name = "burner-redis" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/89/54706febafc135095b2a9d797cfbd4eed2ab1ad7819808b99b587020471b/burner_redis-0.1.7.tar.gz", hash = "sha256:7474ff092669fd11ef765411572cdafcc3d89b8054aef4ca0617be6d6be4c680", size = 638644, upload-time = "2026-05-08T15:01:42.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/5d/198bd1d22e504b3034353430703afbdb3efe6e25cb90bf52d896e1d266a7/burner_redis-0.1.7-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f80c866996e0455d584eb3c0f3b067e411c632fb0519eab454e0968edf01e62c", size = 1288888, upload-time = "2026-05-08T15:01:26.103Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4e/ce5c91b884ac37fcd380756402536f8810964014097950900517ce8bd30c/burner_redis-0.1.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a3d9569a376b690fb5876d454e4904443332dc3ad5c0057e149fc2ad220bf599", size = 1234282, upload-time = "2026-05-08T15:01:28.286Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/31c25cc88143eac2dddcc394151a0db627923d44c94376a83768552c9f13/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20eba1917e3bca9eea5957d5700ff8defcb5a209e57a7841d005549aa0151f44", size = 1337341, upload-time = "2026-05-08T15:01:30.397Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/95cfa1833316ca2b6b2e58150a4900bc1ad256043cdd36198f1887618ccc/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39111467059b8a28f15ea061d2414ec25c3e57c65759983f90f4d358e7d6a72d", size = 1366800, upload-time = "2026-05-08T15:01:32.891Z" }, + { url = "https://files.pythonhosted.org/packages/34/ad/93c3916f053f89b7b5760da5bf855cd78b7885d480f9cfcc64f3732c1dc2/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9b5adfe99aeb8407f468078f3769b2a63e9168fea12f7709df5d2a3b152706e4", size = 1538160, upload-time = "2026-05-08T15:01:34.667Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b9/19bae42cb124932d71168bc8e5bcb1da33aa62b908e5e632b3d298d7cb15/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:591a9d20685f9d6d22bf0c863b50b12dfcf328b06111b3f62c33cd3185d48ce0", size = 1591491, upload-time = "2026-05-08T15:01:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/f5/30/207f47f406619a5b564355d2946c3171f84231a28b800709b5645b06a5ae/burner_redis-0.1.7-cp310-abi3-win_amd64.whl", hash = "sha256:f6cf4ac666766b32fd63940aad0c120847905fd3102c17e5b6b305f91a21d079", size = 1117564, upload-time = "2026-05-08T15:01:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/76/6f/e9beaf46c5e9fd10dfcdb889ebf7d3aa85142c650c0ab17ab284194f58e1/burner_redis-0.1.7-cp310-abi3-win_arm64.whl", hash = "sha256:458f88feeddfb40a586cc3fcbd8e9384bbdfd2a4512a695af4900e06052570d4", size = 1040407, upload-time = "2026-05-08T15:01:41.235Z" }, +] + [[package]] name = "cachetools" version = "6.2.4" @@ -1910,25 +1926,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" }, ] -[[package]] -name = "fakeredis" -version = "2.35.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-16-pydantic-ai-slim-huggingface' and extra == 'extra-16-pydantic-ai-slim-outlines-vllm-offline')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/50/b748233c02fa77e5105238190cc9bb58b852eb1c8b1d0763230d3a5b745a/fakeredis-2.35.1.tar.gz", hash = "sha256:5bae5eba7b9d93cb968944ac40936373cf2397ff71667d4b595df65c3d2e413f", size = 189118, upload-time = "2026-04-12T17:05:58.539Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/27/b8b057a23f7777177e92d3a602fd866751b6b45014964548997e92e048fd/fakeredis-2.35.1-py3-none-any.whl", hash = "sha256:67d97e11f562b7870e11e5c30cf182270bfb2dd37f6707dba47cc6d91628d1b9", size = 129678, upload-time = "2026-04-12T17:05:56.86Z" }, -] - -[package.optional-dependencies] -lua = [ - { name = "lupa" }, -] - [[package]] name = "fasta2a" version = "0.6.1" @@ -3804,80 +3801,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] -[[package]] -name = "lupa" -version = "2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/15/713cab5d0dfa4858f83b99b3e0329072df33dc14fc3ebbaa017e0f9755c4/lupa-2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b3dabda836317e63c5ad052826e156610f356a04b3003dfa0dbe66b5d54d671", size = 954828, upload-time = "2025-10-24T07:17:15.726Z" }, - { url = "https://files.pythonhosted.org/packages/2e/71/704740cbc6e587dd6cc8dabf2f04820ac6a671784e57cc3c29db795476db/lupa-2.6-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8726d1c123bbe9fbb974ce29825e94121824e66003038ff4532c14cc2ed0c51c", size = 1919259, upload-time = "2025-10-24T07:17:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/eb/18/f248341c423c5d48837e35584c6c3eb4acab7e722b6057d7b3e28e42dae8/lupa-2.6-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:f4e159e7d814171199b246f9235ca8961f6461ea8c1165ab428afa13c9289a94", size = 984998, upload-time = "2025-10-24T07:17:20.428Z" }, - { url = "https://files.pythonhosted.org/packages/44/1e/8a4bd471e018aad76bcb9455d298c2c96d82eced20f2ae8fcec8cd800948/lupa-2.6-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:202160e80dbfddfb79316692a563d843b767e0f6787bbd1c455f9d54052efa6c", size = 1174871, upload-time = "2025-10-24T07:17:22.755Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5c/3a3f23fd6a91b0986eea1ceaf82ad3f9b958fe3515a9981fb9c4eb046c8b/lupa-2.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5deede7c5b36ab64f869dae4831720428b67955b0bb186c8349cf6ea121c852b", size = 1057471, upload-time = "2025-10-24T07:17:24.908Z" }, - { url = "https://files.pythonhosted.org/packages/45/ac/01be1fed778fb0c8f46ee8cbe344e4d782f6806fac12717f08af87aa4355/lupa-2.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86f04901f920bbf7c0cac56807dc9597e42347123e6f1f3ca920f15f54188ce5", size = 2100592, upload-time = "2025-10-24T07:17:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/3f/6c/1a05bb873e30830f8574e10cd0b4cdbc72e9dbad2a09e25810b5e3b1f75d/lupa-2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6deef8f851d6afb965c84849aa5b8c38856942df54597a811ce0369ced678610", size = 1081396, upload-time = "2025-10-24T07:17:29.064Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c2/a19dd80d6dc98b39bbf8135b8198e38aa7ca3360b720eac68d1d7e9286b5/lupa-2.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:21f2b5549681c2a13b1170a26159d30875d367d28f0247b81ca347222c755038", size = 1192007, upload-time = "2025-10-24T07:17:31.362Z" }, - { url = "https://files.pythonhosted.org/packages/4f/43/e1b297225c827f55752e46fdbfb021c8982081b0f24490e42776ea69ae3b/lupa-2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:66eea57630eab5e6f49fdc5d7811c0a2a41f2011be4ea56a087ea76112011eb7", size = 2196661, upload-time = "2025-10-24T07:17:33.484Z" }, - { url = "https://files.pythonhosted.org/packages/2e/8f/2272d429a7fa9dc8dbd6e9c5c9073a03af6007eb22a4c78829fec6a34b80/lupa-2.6-cp310-cp310-win32.whl", hash = "sha256:60a403de8cab262a4fe813085dd77010effa6e2eb1886db2181df803140533b1", size = 1412738, upload-time = "2025-10-24T07:17:35.11Z" }, - { url = "https://files.pythonhosted.org/packages/35/2a/1708911271dd49ad87b4b373b5a4b0e0a0516d3d2af7b76355946c7ee171/lupa-2.6-cp310-cp310-win_amd64.whl", hash = "sha256:e4656a39d93dfa947cf3db56dc16c7916cb0cc8024acd3a952071263f675df64", size = 1656898, upload-time = "2025-10-24T07:17:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/ca/29/1f66907c1ebf1881735afa695e646762c674f00738ebf66d795d59fc0665/lupa-2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d988c0f9331b9f2a5a55186701a25444ab10a1432a1021ee58011499ecbbdd5", size = 962875, upload-time = "2025-10-24T07:17:39.107Z" }, - { url = "https://files.pythonhosted.org/packages/e6/67/4a748604be360eb9c1c215f6a0da921cd1a2b44b2c5951aae6fb83019d3a/lupa-2.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ebe1bbf48259382c72a6fe363dea61a0fd6fe19eab95e2ae881e20f3654587bf", size = 1935390, upload-time = "2025-10-24T07:17:41.427Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0c/8ef9ee933a350428b7bdb8335a37ef170ab0bb008bbf9ca8f4f4310116b6/lupa-2.6-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:a8fcee258487cf77cdd41560046843bb38c2e18989cd19671dd1e2596f798306", size = 992193, upload-time = "2025-10-24T07:17:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/65/46/e6c7facebdb438db8a65ed247e56908818389c1a5abbf6a36aab14f1057d/lupa-2.6-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:561a8e3be800827884e767a694727ed8482d066e0d6edfcbf423b05e63b05535", size = 1165844, upload-time = "2025-10-24T07:17:45.437Z" }, - { url = "https://files.pythonhosted.org/packages/1c/26/9f1154c6c95f175ccbf96aa96c8f569c87f64f463b32473e839137601a8b/lupa-2.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af880a62d47991cae78b8e9905c008cbfdc4a3a9723a66310c2634fc7644578c", size = 1048069, upload-time = "2025-10-24T07:17:47.181Z" }, - { url = "https://files.pythonhosted.org/packages/68/67/2cc52ab73d6af81612b2ea24c870d3fa398443af8e2875e5befe142398b1/lupa-2.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b22923aa4023c86c0097b235615f89d469a0c4eee0489699c494d3367c4c85", size = 2079079, upload-time = "2025-10-24T07:17:49.755Z" }, - { url = "https://files.pythonhosted.org/packages/2e/dc/f843f09bbf325f6e5ee61730cf6c3409fc78c010d968c7c78acba3019ca7/lupa-2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:153d2cc6b643f7efb9cfc0c6bb55ec784d5bac1a3660cfc5b958a7b8f38f4a75", size = 1071428, upload-time = "2025-10-24T07:17:51.991Z" }, - { url = "https://files.pythonhosted.org/packages/2e/60/37533a8d85bf004697449acb97ecdacea851acad28f2ad3803662487dd2a/lupa-2.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3fa8777e16f3ded50b72967dc17e23f5a08e4f1e2c9456aff2ebdb57f5b2869f", size = 1181756, upload-time = "2025-10-24T07:17:53.752Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/cf29b20dbb4927b6a3d27c339ac5d73e74306ecc28c8e2c900b2794142ba/lupa-2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8dbdcbe818c02a2f56f5ab5ce2de374dab03e84b25266cfbaef237829bc09b3f", size = 2175687, upload-time = "2025-10-24T07:17:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/94/7c/050e02f80c7131b63db1474bff511e63c545b5a8636a24cbef3fc4da20b6/lupa-2.6-cp311-cp311-win32.whl", hash = "sha256:defaf188fde8f7a1e5ce3a5e6d945e533b8b8d547c11e43b96c9b7fe527f56dc", size = 1412592, upload-time = "2025-10-24T07:17:59.062Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/6f2af98aa5d771cea661f66c8eb8f53772ec1ab1dfbce24126cfcd189436/lupa-2.6-cp311-cp311-win_amd64.whl", hash = "sha256:9505ae600b5c14f3e17e70f87f88d333717f60411faca1ddc6f3e61dce85fa9e", size = 1669194, upload-time = "2025-10-24T07:18:01.647Z" }, - { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, - { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, - { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, - { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, - { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, - { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, - { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, - { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, - { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, - { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, - { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, - { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, - { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, - { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, - { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, - { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, - { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, - { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, - { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, - { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, - { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, -] - [[package]] name = "lxml" version = "6.0.2" @@ -6742,6 +6665,7 @@ dev = [ { name = "inline-snapshot" }, { name = "markdownify" }, { name = "pip" }, + { name = "pydocket" }, { name = "pytest" }, { name = "pytest-examples" }, { name = "pytest-mock" }, @@ -6806,6 +6730,7 @@ dev = [ { name = "inline-snapshot", specifier = ">=0.32.5" }, { name = "markdownify", specifier = ">=1.2" }, { name = "pip", specifier = ">=26.0" }, + { name = "pydocket", specifier = ">=0.20.2" }, { name = "pytest", specifier = ">=9.0.0" }, { name = "pytest-examples", specifier = ">=0.0.18" }, { name = "pytest-mock", specifier = ">=3.14.0" }, @@ -7307,13 +7232,13 @@ wheels = [ [[package]] name = "pydocket" -version = "0.19.0" +version = "0.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "burner-redis" }, { name = "cloudpickle" }, { name = "cronsim" }, { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'extra-16-pydantic-ai-slim-huggingface' and extra == 'extra-16-pydantic-ai-slim-outlines-vllm-offline')" }, - { name = "fakeredis", extra = ["lua"] }, { name = "opentelemetry-api" }, { name = "prometheus-client" }, { name = "py-key-value-aio", extra = ["memory", "redis"] }, @@ -7326,9 +7251,9 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32' or (extra == 'extra-16-pydantic-ai-slim-huggingface' and extra == 'extra-16-pydantic-ai-slim-outlines-vllm-offline')" }, { name = "uncalled-for" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/6e/0db603ce4d82072b1a61798340e408ec04b3a77647f537881ff5b93c31f6/pydocket-0.19.0.tar.gz", hash = "sha256:00bff620d80cd2fad34ccbbe526dce24a9de8cdc1d2b94d305739668a98e308a", size = 355531, upload-time = "2026-04-10T17:25:38.112Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/c3/8df43eeb0151c73f6514417a3adcaaa3c10fc301326ae16bcbb1d3d4a13b/pydocket-0.20.2.tar.gz", hash = "sha256:e5444ad28beeccfbe43370291333030ae61516c5197d9c2269cf2cc55bcb8d97", size = 380338, upload-time = "2026-05-11T14:51:35.913Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/46/7bed93ecff9015c4a8dcabfaab3d490b45ec8e5847b30ac9671b9c01def8/pydocket-0.19.0-py3-none-any.whl", hash = "sha256:8531e64b989673a17d055ee4498ca8c3505310c5af4e7fd09c7b00fb2f29aa19", size = 99271, upload-time = "2026-04-10T17:25:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b5/51/cf9c41add3b51102f0f716ed1a6bfb6ceb6274ad67b4096ef2f69524032b/pydocket-0.20.2-py3-none-any.whl", hash = "sha256:35676bd376ce932018473e7db6d078e0880088824abdb135105bcb8a7b7482b7", size = 110290, upload-time = "2026-05-11T14:51:34.255Z" }, ] [[package]] @@ -8994,15 +8919,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, ] -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - [[package]] name = "soupsieve" version = "2.8.1" @@ -9736,11 +9652,11 @@ wheels = [ [[package]] name = "uncalled-for" -version = "0.2.0" +version = "0.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" }, + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, ] [[package]] From 5b5f474c6bead4af336d2f2c88a2cadb1d02c057 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 15 May 2026 23:11:44 +0000 Subject: [PATCH 2/4] docs: add Background tasks section to MCPToolset docstring --- pydantic_ai_slim/pydantic_ai/mcp.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index f28f39eed9..66796f34ed 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -1683,6 +1683,31 @@ class MCPToolset(AbstractToolset[AgentDepsT]): client = Client(StreamableHttpTransport('http://localhost:8000/mcp'), auth='oauth') toolset = MCPToolset(client) ``` + + ## Background tasks + + For tools that declare `task=TaskConfig(mode='required'|'optional')` server-side, + `MCPToolset` supports MCP task-augmented execution per + [SEP-1686](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) — the + server wraps the call in a durable, cancelable, pollable task. Each task-supporting tool exposes + `task=True` (and `task_required=True` for `mode='required'`) in its `ToolDefinition.metadata` so + a capability can opt them in by setting `background=True`: + + ```python {test="skip"} + from pydantic_ai import Agent + from pydantic_ai.capabilities import SetToolMetadata + from pydantic_ai.mcp import MCPToolset + + agent = Agent( + 'openai:gpt-5', + toolsets=[MCPToolset('http://localhost:8000/mcp')], + capabilities=[SetToolMetadata(tools={'task': True}, background=True)], + ) + ``` + + Without an opt-in, `mode='required'` tools raise `UserError` (since the server would otherwise + return `-32601: requires task-augmented execution`) and `mode='optional'` tools fall back to the + regular sync path. """ client: FastMCPClient[Any] From 1d014283cc790b215382858301779e5f0509af38 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 20 May 2026 01:06:08 +0000 Subject: [PATCH 3/4] Respect server's taskSupport unconditionally; drop client-side opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/mcp/client.md | 44 ++++++++++++ pydantic_ai_slim/pydantic_ai/mcp.py | 47 +----------- tests/test_mcp_toolset.py | 108 ++++++++-------------------- 3 files changed, 78 insertions(+), 121 deletions(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 104a2695e5..60a1b20c52 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -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. +[`MCPToolset`][pydantic_ai.mcp.MCPToolset] additionally exposes a `task: bool` flag indicating whether the server declares support for [task-augmented execution](#background-tasks) on the tool. + +## Background tasks + +[`MCPToolset`][pydantic_ai.mcp.MCPToolset] supports MCP [task-augmented execution](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) (SEP-1686). Servers can declare per-tool task support via `execution.taskSupport`, and `MCPToolset` routes calls accordingly: + +| `execution.taskSupport` | Behavior | +| --- | --- | +| `"required"` | Always calls with `task=True`. The server creates a task and the client awaits the final result via `tasks/result`. | +| `"optional"` | Always calls with `task=True` to opt in to durability, cancellation, and progress notifications. | +| `"forbidden"` or absent | Calls normally. | + +For [FastMCP](https://gofastmcp.com/) servers, declare task support per tool with `task=TaskConfig(mode=...)`: + +```python {title="background_task_server.py" dunder_name="not_main"} +from fastmcp import FastMCP +from fastmcp.server.tasks import TaskConfig + +mcp = FastMCP('long_running_server') + + +@mcp.tool(task=TaskConfig(mode='required')) +async def deep_research(topic: str) -> str: + import asyncio + await asyncio.sleep(0) + return f'Researched {topic}' + + +if __name__ == '__main__': + mcp.run(transport='streamable-http') +``` + +The client side needs no extra configuration — `MCPToolset` sends `task=True` automatically based on the server's declaration: + +```python {title="background_task_client.py"} +from pydantic_ai import Agent +from pydantic_ai.mcp import MCPToolset + +toolset = MCPToolset('http://localhost:8000/mcp') +agent = Agent('openai:gpt-5', toolsets=[toolset]) +``` + +See [Streamable HTTP Client](#streamable-http-client) for the full `agent.run` plumbing. + ## Resources MCP servers can provide [resources](https://modelcontextprotocol.io/docs/concepts/resources) - files, data, or content that can be accessed by the client. Resources in MCP are application-driven, with host applications determining how to incorporate context manually, based on their needs. This means they will _not_ be exposed to the LLM automatically (unless a tool returns a `ResourceLink` or `EmbeddedResource`). diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 1fe3e3c9e4..6d4d423135 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -1682,31 +1682,6 @@ class MCPToolset(AbstractToolset[AgentDepsT]): client = Client(StreamableHttpTransport('http://localhost:8000/mcp'), auth='oauth') toolset = MCPToolset(client) ``` - - ## Background tasks - - For tools that declare `task=TaskConfig(mode='required'|'optional')` server-side, - `MCPToolset` supports MCP task-augmented execution per - [SEP-1686](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) — the - server wraps the call in a durable, cancelable, pollable task. Each task-supporting tool exposes - `task=True` (and `task_required=True` for `mode='required'`) in its `ToolDefinition.metadata` so - a capability can opt them in by setting `background=True`: - - ```python {test="skip"} - from pydantic_ai import Agent - from pydantic_ai.capabilities import SetToolMetadata - from pydantic_ai.mcp import MCPToolset - - agent = Agent( - 'openai:gpt-5', - toolsets=[MCPToolset('http://localhost:8000/mcp')], - capabilities=[SetToolMetadata(tools={'task': True}, background=True)], - ) - ``` - - Without an opt-in, `mode='required'` tools raise `UserError` (since the server would otherwise - return `-32601: requires task-augmented execution`) and `mode='optional'` tools fall back to the - regular sync path. """ client: FastMCPClient[Any] @@ -2122,7 +2097,6 @@ async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[ 'meta': mcp_tool.meta, 'annotations': mcp_tool.annotations.model_dump() if mcp_tool.annotations else None, 'task': task_support in ('required', 'optional'), - 'task_required': task_support == 'required', }, return_schema=mcp_tool.outputSchema or None, include_return_schema=self.include_return_schema, @@ -2198,24 +2172,9 @@ async def call_tool( ctx: RunContext[Any], tool: ToolsetTool[Any], ) -> Any: - metadata = tool.tool_def.metadata or {} - supports_task = bool(metadata.get('task')) - task_required = bool(metadata.get('task_required')) - - # Whether to opt in to the MCP task path is read from the *runtime* tool metadata - # (post-capability augmentation, e.g. by `SetToolMetadata`), not the static toolset view. - runtime_tool = ctx.tool_manager.tools.get(name) if ctx.tool_manager and ctx.tool_manager.tools else None - background = bool((runtime_tool.tool_def.metadata or {}).get('background')) if runtime_tool else False - - if task_required and not background: - raise exceptions.UserError( - f'Tool {name!r} requires MCP task-augmented execution but no capability has opted it in. ' - f"Add `SetToolMetadata(tools={{'task': True}}, background=True)` to your agent's capabilities, " - f"or any equivalent that sets `background=True` on the tool's metadata." - ) - - use_task = supports_task and background - + # Server-side task-augmented execution per MCP SEP-1686 is governed entirely by the tool's + # `execution.taskSupport`: 'required'/'optional' → task path; 'forbidden' or absent → regular path. + use_task = bool((tool.tool_def.metadata or {}).get('task')) if self.process_tool_call is not None: return await self.process_tool_call( ctx, functools.partial(self.direct_call_tool, use_task=use_task), name, tool_args diff --git a/tests/test_mcp_toolset.py b/tests/test_mcp_toolset.py index 23ebe736a7..6ad658b27b 100644 --- a/tests/test_mcp_toolset.py +++ b/tests/test_mcp_toolset.py @@ -13,7 +13,6 @@ class is validated against the same surface area as the legacy `FastMCPToolset`. import asyncio import base64 -import dataclasses import importlib import json import sys @@ -27,9 +26,8 @@ class is validated against the same surface area as the legacy `FastMCPToolset`. from pydantic_ai import models from pydantic_ai._run_context import RunContext -from pydantic_ai.exceptions import ModelRetry, UserError +from pydantic_ai.exceptions import ModelRetry from pydantic_ai.models.test import TestModel -from pydantic_ai.tool_manager import ToolManager from pydantic_ai.tools import ToolDefinition from pydantic_ai.usage import RunUsage @@ -736,13 +734,10 @@ async def test_load_mcp_toolsets_http_entry(self): class TestMCPToolsetBackgroundTasks: - """SEP-1686 task-augmented execution. Tools whose server config declares - `task=TaskConfig(mode='required'|'optional')` opt into a durable, cancelable, pollable execution path — - `client.call_tool(task=True)` → `tool_task.result()`. - - The client-side opt-in is a `background=True` flag on the tool's runtime metadata, set by any capability - (e.g. `SetToolMetadata(tools={'task': True}, background=True)`). Without the opt-in, `mode='required'` - raises `UserError` and `mode='optional'` falls back to the regular sync path.""" + """SEP-1686 task-augmented execution. `MCPToolset` reads each tool's server-declared + `execution.taskSupport` and routes the call accordingly: + `'required'` and `'optional'` go through `client.call_tool(task=True)` → `tool_task.result()`, + while `'forbidden'`/absent stay on the regular sync path.""" @pytest.fixture async def task_server(self) -> FastMCP[None]: @@ -760,74 +755,61 @@ async def task_optional_tool() -> str: await asyncio.sleep(0) return 'task_optional_completed' + @server.tool() + async def plain_tool() -> str: + """A tool with no task support — `execution` is `None`.""" + return 'plain_completed' + return server async def test_get_tools_exposes_task_metadata( self, task_server: FastMCP[None], run_context: RunContext[None] ) -> None: - """`get_tools` exposes `task` (any task support) and `task_required` so capabilities like - `SetToolMetadata(tools={'task': True}, background=True)` can target task-augmented tools.""" + """`get_tools` exposes `task: bool` so downstream capabilities can target task-augmented tools.""" toolset = MCPToolset(task_server) async with toolset: tools = await toolset.get_tools(run_context) - required_meta = tools['task_required_tool'].tool_def.metadata or {} - optional_meta = tools['task_optional_tool'].tool_def.metadata or {} - assert required_meta['task'] is True - assert required_meta['task_required'] is True - assert optional_meta['task'] is True - assert optional_meta['task_required'] is False + assert (tools['task_required_tool'].tool_def.metadata or {}).get('task') is True + assert (tools['task_optional_tool'].tool_def.metadata or {}).get('task') is True + assert (tools['plain_tool'].tool_def.metadata or {}).get('task') is False - async def test_required_tool_without_opt_in_raises( + async def test_required_tool_routes_through_task_path( self, task_server: FastMCP[None], run_context: RunContext[None] ) -> None: - """`mode='required'` without `background=True` in the runtime metadata raises `UserError` before - any RPC — the call would otherwise fail with a server-side `Method not found`.""" + """`mode='required'` succeeds — getting the real result proves `task=True` was sent (the server + would otherwise return `-32601: requires task-augmented execution`).""" toolset = MCPToolset(task_server) async with toolset: tools = await toolset.get_tools(run_context) - ctx = _ctx_with_runtime_tools(run_context, toolset, tools) - with pytest.raises(UserError, match='requires MCP task-augmented execution'): - await toolset.call_tool('task_required_tool', {}, ctx, tools['task_required_tool']) + result = await toolset.call_tool('task_required_tool', {}, run_context, tools['task_required_tool']) + assert result == 'task_required_completed' - async def test_optional_tool_without_opt_in_uses_sync_path( + async def test_optional_tool_routes_through_task_path( self, task_server: FastMCP[None], run_context: RunContext[None] ) -> None: - """`mode='optional'` without an opt-in falls back to the regular sync call — no error, real result.""" + """`mode='optional'` also goes through the task path by default — the SEP allows either, and the + task path delivers durability/cancellation/progress benefits with no functional downside.""" toolset = MCPToolset(task_server) async with toolset: tools = await toolset.get_tools(run_context) - ctx = _ctx_with_runtime_tools(run_context, toolset, tools) - result = await toolset.call_tool('task_optional_tool', {}, ctx, tools['task_optional_tool']) + result = await toolset.call_tool('task_optional_tool', {}, run_context, tools['task_optional_tool']) assert result == 'task_optional_completed' - async def test_required_tool_with_opt_in_uses_task_path( - self, task_server: FastMCP[None], run_context: RunContext[None] - ) -> None: - """With `background=True` on the runtime tool, a `mode='required'` tool succeeds via the task path — - getting back the real result proves `task=True` was sent (the server would otherwise return - `-32601: requires task-augmented execution`).""" - toolset = MCPToolset(task_server) - async with toolset: - tools = await toolset.get_tools(run_context) - ctx = _ctx_with_runtime_tools(run_context, toolset, tools, opted_in={'task_required_tool'}) - result = await toolset.call_tool('task_required_tool', {}, ctx, tools['task_required_tool']) - assert result == 'task_required_completed' - - async def test_optional_tool_with_opt_in_uses_task_path( + async def test_plain_tool_stays_on_sync_path( self, task_server: FastMCP[None], run_context: RunContext[None] ) -> None: - """`mode='optional'` + `background=True` opts into the task path.""" + """A tool with no `execution.taskSupport` stays on the regular sync `tools/call`. Sending + `task=True` to such a server would violate the SEP.""" toolset = MCPToolset(task_server) async with toolset: tools = await toolset.get_tools(run_context) - ctx = _ctx_with_runtime_tools(run_context, toolset, tools, opted_in={'task_optional_tool'}) - result = await toolset.call_tool('task_optional_tool', {}, ctx, tools['task_optional_tool']) - assert result == 'task_optional_completed' + result = await toolset.call_tool('plain_tool', {}, run_context, tools['plain_tool']) + assert result == 'plain_completed' async def test_direct_call_tool_with_use_task(self, task_server: FastMCP[None]) -> None: - """`direct_call_tool(..., use_task=True)` takes the task path — `mode='required'` works without - the higher-level opt-in plumbing, since users calling `direct_call_tool` already know what they want.""" + """`direct_call_tool(..., use_task=True)` is the low-level escape hatch for users calling without + a `ToolDefinition` — `mode='required'` works directly.""" toolset = MCPToolset(task_server) async with toolset: result = await toolset.direct_call_tool('task_required_tool', {}, use_task=True) @@ -845,38 +827,10 @@ async def passthrough(ctx: RunContext[Any], call_tool: Any, name: str, args: dic toolset = MCPToolset(task_server, process_tool_call=passthrough) async with toolset: tools = await toolset.get_tools(run_context) - ctx = _ctx_with_runtime_tools(run_context, toolset, tools, opted_in={'task_required_tool'}) - result = await toolset.call_tool('task_required_tool', {}, ctx, tools['task_required_tool']) + result = await toolset.call_tool('task_required_tool', {}, run_context, tools['task_required_tool']) assert result == 'task_required_completed' -def _ctx_with_runtime_tools( - ctx: RunContext[None], - toolset: MCPToolset[None], - tools: dict[str, Any], - *, - opted_in: set[str] | None = None, -) -> RunContext[None]: - """Build a `RunContext` whose `tool_manager.tools` mirrors `tools` but adds `background=True` to the - runtime metadata for tools in `opted_in` — emulating what `SetToolMetadata(tools={'task': True}, - background=True)` would do at runtime.""" - opted_in = opted_in or set() - runtime_tools = { - name: dataclasses.replace( - tool, - tool_def=dataclasses.replace( - tool.tool_def, - metadata={**(tool.tool_def.metadata or {}), 'background': True}, - ), - ) - if name in opted_in - else tool - for name, tool in tools.items() - } - tool_manager: ToolManager[None] = ToolManager(toolset=toolset, tools=runtime_tools) - return dataclasses.replace(ctx, tool_manager=tool_manager) - - def test_construction_does_not_emit_warnings(recwarn: Any) -> None: """Building an `MCPToolset` from a URL must not emit `FastMCPDeprecationWarning` for the `sse_read_timeout` parameter — the StreamableHttp path migrated off it (the FastMCP `Client` From ee963513e7e7769c64ee459c998b71f7a7822929 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 21 May 2026 19:05:20 +0000 Subject: [PATCH 4/4] Address review: fix stale output_schema docs, drop redundant cross-ref, 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. --- docs/mcp/client.md | 6 ++---- pydantic_ai_slim/pydantic_ai/tools.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 60a1b20c52..a2c9620765 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -352,7 +352,7 @@ agent = Agent('openai:gpt-5.2', toolsets=[server]) ## Tool metadata -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. +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` and `annotations` fields can be found on the `metadata` dict on the [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] object that's passed to filter functions, and the tool's output schema (if any) is available as the `return_schema` field. [`MCPToolset`][pydantic_ai.mcp.MCPToolset] additionally exposes a `task: bool` flag indicating whether the server declares support for [task-augmented execution](#background-tasks) on the tool. @@ -393,11 +393,9 @@ from pydantic_ai import Agent from pydantic_ai.mcp import MCPToolset toolset = MCPToolset('http://localhost:8000/mcp') -agent = Agent('openai:gpt-5', toolsets=[toolset]) +agent = Agent('openai:gpt-5.2', toolsets=[toolset]) ``` -See [Streamable HTTP Client](#streamable-http-client) for the full `agent.run` plumbing. - ## Resources MCP servers can provide [resources](https://modelcontextprotocol.io/docs/concepts/resources) - files, data, or content that can be accessed by the client. Resources in MCP are application-driven, with host applications determining how to incorporate context manually, based on their needs. This means they will _not_ be exposed to the LLM automatically (unless a tool returns a `ResourceLink` or `EmbeddedResource`). diff --git a/pydantic_ai_slim/pydantic_ai/tools.py b/pydantic_ai_slim/pydantic_ai/tools.py index dd6353a7a1..5bc63c14de 100644 --- a/pydantic_ai_slim/pydantic_ai/tools.py +++ b/pydantic_ai_slim/pydantic_ai/tools.py @@ -734,7 +734,7 @@ class ToolDefinition: metadata: dict[str, Any] | None = None """Tool metadata that can be set by the toolset this tool came from. It is not sent to the model, but can be used for filtering and tool behavior customization. - For MCP tools, this contains the `meta`, `annotations`, and `output_schema` fields from the tool definition. + For MCP tools, this contains the `meta` and `annotations` fields from the tool definition, as well as a `task` flag indicating whether the server declares support for task-augmented execution. """ timeout: float | None = None