Files
wehub-resource-sync 9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
Harness Compat / harness compat (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

1440 lines
64 KiB
Python

"""Tests for `pydantic_ai.mcp.MCPToolset`.
`TestMCPToolsetConstruction` covers construction-time behavior — kwarg conflict detection, HTTP
transport adapter for `http_client=`, sampling shortcut, the cache-invalidating message handler.
`TestMCPToolsetIntegration` exercises lifecycle, tool calling, resource methods, and caching
against an in-process FastMCP server.
"""
from __future__ import annotations
import asyncio
import base64
import json
import re
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from unittest.mock import AsyncMock
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from pydantic_ai import models
from pydantic_ai._run_context import RunContext
from pydantic_ai._utils import BaseExceptionGroup
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.models.test import TestModel
from pydantic_ai.usage import RunUsage
from .conftest import try_import
with try_import() as imports_successful:
from fastmcp.client import Client
from fastmcp.client.transports import (
SSETransport,
StreamableHttpTransport,
)
from fastmcp.exceptions import ToolError
from fastmcp.prompts import Message
from fastmcp.server import FastMCP
from fastmcp.server.tasks import TaskConfig
from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
from mcp.types import (
Annotations,
AudioContent,
BlobResourceContents,
EmbeddedResource,
ImageContent,
ResourceLink,
TextContent as McpTextContent,
TextResourceContents,
)
from pydantic import AnyUrl
from pydantic_ai.mcp import (
MCPError,
MCPToolset,
Prompt,
PromptArgument,
PromptMessage,
PromptResult,
ResourceAnnotations,
ResourceTemplate,
ServerCapabilities,
_make_httpx_client_factory, # pyright: ignore[reportPrivateUsage]
load_mcp_toolsets,
)
from pydantic_ai.messages import TextContent
pytestmark = [
pytest.mark.skipif(not imports_successful(), reason='fastmcp not installed'),
pytest.mark.anyio,
]
# Construction tests don't need a server and don't take async fixtures.
class TestMCPToolsetConstruction:
def test_url_builds_streamable_http_transport(self):
toolset = MCPToolset('https://example.com/mcp')
assert isinstance(toolset.client.transport, StreamableHttpTransport)
def test_sse_url_builds_sse_transport_with_headers(self):
toolset = MCPToolset('https://example.com/sse', headers={'X-Key': 'foo'})
assert isinstance(toolset.client.transport, SSETransport)
assert toolset.client.transport.headers == {'X-Key': 'foo'}
def test_url_with_headers_routes_through_explicit_transport(self):
toolset = MCPToolset('https://example.com/mcp', headers={'X-Key': 'foo'})
assert isinstance(toolset.client.transport, StreamableHttpTransport)
assert toolset.client.transport.headers == {'X-Key': 'foo'}
def test_http_client_kwarg_uses_factory(self):
client = httpx.AsyncClient()
toolset = MCPToolset('https://example.com/mcp', http_client=client)
assert isinstance(toolset.client.transport, StreamableHttpTransport)
assert toolset.client.transport.httpx_client_factory is not None
assert toolset.client.transport.httpx_client_factory() is client
# FastMCP's StreamableHttpTransport calls the factory with `follow_redirects`, which the
# mcp SDK's `McpHttpClientFactory` protocol doesn't declare; the factory must accept it.
factory = _make_httpx_client_factory(client)
assert factory(follow_redirects=True) is client
def test_sse_url_with_http_client_uses_factory(self):
client = httpx.AsyncClient()
toolset = MCPToolset('https://example.com/sse', http_client=client)
assert isinstance(toolset.client.transport, SSETransport)
assert toolset.client.transport.httpx_client_factory is not None
assert toolset.client.transport.httpx_client_factory() is client
factory = _make_httpx_client_factory(client)
assert factory(follow_redirects=True) is client
def test_http_kwargs_with_non_url_input_raises(self):
"""HTTP-only kwargs (headers/auth/verify/http_client) must error out when the connection
target isn't an HTTP URL — otherwise the kwargs are silently dropped on stdio / Path /
in-process inputs."""
from fastmcp.server import FastMCP
with pytest.raises(ValueError, match='only apply to HTTP transports built from a URL'):
MCPToolset(FastMCP(name='in_process'), headers={'X-Key': 'foo'})
def test_headers_and_http_client_conflict_raises(self):
with pytest.raises(ValueError, match='mutually exclusive'):
MCPToolset(
'https://example.com/mcp',
headers={'X-Key': 'foo'},
http_client=httpx.AsyncClient(),
)
def test_pre_built_client_with_handler_kwargs_raises(self):
client = Client('https://example.com/mcp')
with pytest.raises(ValueError, match=re.escape('pre-built `fastmcp.Client`')):
MCPToolset(client, headers={'X-Key': 'foo'})
def test_pre_built_client_with_overridden_init_timeout_raises(self):
client = Client('https://example.com/mcp')
with pytest.raises(ValueError, match='init_timeout'):
MCPToolset(client, init_timeout=30)
def test_pre_built_client_with_overridden_read_timeout_raises(self):
client = Client('https://example.com/mcp')
with pytest.raises(ValueError, match='read_timeout'):
MCPToolset(client, read_timeout=30)
def test_pre_built_client_used_as_is(self):
client = Client('https://example.com/mcp')
toolset = MCPToolset(client)
assert toolset.client is client
def test_sampling_model_and_handler_conflict(self):
with pytest.raises(ValueError, match=r'sampling_model.*sampling_handler'):
MCPToolset(
'https://example.com/mcp',
sampling_model=models.infer_model('test'),
sampling_handler=lambda *_: None, # type: ignore[arg-type,return-value]
)
def test_sampling_model_installs_handler(self):
toolset = MCPToolset('https://example.com/mcp', sampling_model=models.infer_model('test'))
assert toolset.client._session_kwargs.get('sampling_callback') is not None # pyright: ignore[reportPrivateUsage]
def test_id_property(self):
toolset = MCPToolset('https://example.com/mcp', id='example')
assert toolset.id == 'example'
def test_repr_includes_id(self):
toolset = MCPToolset('https://example.com/mcp', id='example')
assert "id='example'" in repr(toolset)
def test_repr_without_id(self):
toolset = MCPToolset('https://example.com/mcp')
assert 'MCPToolset(client=' in repr(toolset)
def test_pre_init_property_access_raises(self):
toolset = MCPToolset('https://example.com/mcp')
with pytest.raises(AttributeError, match='only available after initialization'):
_ = toolset.server_info
with pytest.raises(AttributeError, match='only available after initialization'):
_ = toolset.capabilities
with pytest.raises(AttributeError, match='only available after initialization'):
_ = toolset.instructions
assert toolset.is_running is False
def test_tool_name_conflict_hint_mentions_prefixed(self):
toolset = MCPToolset('https://example.com/mcp')
assert '.prefixed' in toolset.tool_name_conflict_hint
def test_eq_and_hash(self):
client = Client('https://example.com/mcp')
a = MCPToolset(client, id='same')
b = MCPToolset(client, id='same')
c = MCPToolset(client, id='other')
assert a == b
assert a != c
assert hash(a) == hash(b)
def test_id_setter(self):
toolset = MCPToolset('https://example.com/mcp')
toolset.id = 'new'
assert toolset.id == 'new'
def test_explicit_timeouts_override_defaults(self):
"""Passing `init_timeout` / `read_timeout` explicitly bypasses the `_UNSET` sentinel
resolution branch."""
toolset = MCPToolset('https://example.com/mcp', init_timeout=10, read_timeout=120)
# Both kwargs flow into the FastMCP `Client`; verify the read timeout was forwarded.
assert toolset.client._init_timeout is not None # pyright: ignore[reportPrivateUsage]
class TestResourceTypeMapping:
"""The PAI-shaped `Resource` / `ResourceTemplate` / `MCPError` types are kept under
`pydantic_ai.mcp.*`. They were ported from the deprecated `MCPServer*` path; these tests pin
the wire-level field mapping so drifts from the MCP SDK schema are caught."""
def test_resource_template_from_mcp_sdk(self):
sdk_template = mcp_types.ResourceTemplate(
uriTemplate='file:///{path}',
name='file',
title='File',
description='Read a file',
mimeType='application/octet-stream',
annotations=mcp_types.Annotations(audience=['user'], priority=0.7),
_meta={'origin': 'test'},
)
template = ResourceTemplate.from_mcp_sdk(sdk_template)
assert template.uri_template == 'file:///{path}'
assert template.name == 'file'
assert template.title == 'File'
assert template.description == 'Read a file'
assert template.mime_type == 'application/octet-stream'
assert isinstance(template.annotations, ResourceAnnotations)
assert template.annotations.audience == ['user']
assert template.annotations.priority == 0.7
assert template.metadata == {'origin': 'test'}
def test_mcp_error_str_includes_code_and_data(self):
err = MCPError('boom', code=-32002, data={'extra': 1})
assert 'boom' in str(err)
assert '-32002' in str(err)
assert 'extra' in str(err)
def test_mcp_error_str_without_data(self):
err = MCPError('boom', code=-32002)
assert 'boom' in str(err)
assert '-32002' in str(err)
@pytest.fixture
async def fastmcp_server() -> FastMCP[None]:
"""In-process FastMCP server with a representative tool/resource surface."""
server: FastMCP[None] = FastMCP('test_server', instructions='You are an MCP test server.')
@server.tool()
async def echo(message: str) -> str:
"""Echo a message back."""
return f'Echo: {message}'
@server.tool()
async def add(a: int, b: int) -> dict[str, int]:
"""Add two numbers and return the result."""
return {'sum': a + b}
@server.tool()
async def boom() -> str:
"""A tool that always raises an error — used to test error handling."""
raise ValueError('boom')
@server.tool()
async def image_tool() -> ImageContent:
"""A tool that returns an image content block."""
encoded = base64.b64encode(b'fake_image_bytes').decode('utf-8')
return ImageContent(type='image', data=encoded, mimeType='image/png')
@server.tool()
async def embedded_blob_tool() -> EmbeddedResource:
"""A tool that returns an embedded blob resource."""
encoded = base64.b64encode(b'fake_blob_bytes').decode('utf-8')
return EmbeddedResource(
type='resource',
resource=BlobResourceContents(uri=AnyUrl('resource://blob.bin'), blob=encoded),
)
@server.tool()
async def resource_link_tool() -> ResourceLink:
"""A tool that returns a resource link."""
return ResourceLink(type='resource_link', uri=AnyUrl('resource://greeting.txt'), name='greeting')
@server.resource('resource://greeting.txt')
async def greeting() -> str:
return 'Hello, world!'
@server.resource('resource://{name}/profile.json')
async def profile(name: str) -> str:
return f'{{"name": "{name}"}}'
_register_prompts(server)
return server
def _register_prompts(server: FastMCP[None]) -> None:
@server.prompt()
def simple_prompt() -> str:
"""A simple prompt template."""
return 'This is a simple prompt'
@server.prompt()
def parameterized_prompt(name: str, topic: str) -> str:
"""A prompt template with parameters."""
return f"Hello {name}, let's talk about {topic}!"
@server.prompt()
def annotated_text_prompt() -> list[Message]:
"""A prompt template with annotated text content."""
return [
Message(
content=McpTextContent(
type='text',
text='annotated text',
annotations=Annotations(audience=['user'], priority=1.0),
)
)
]
@server.prompt()
def text_meta_prompt() -> list[Message]:
"""A prompt template with `_meta` text metadata."""
return [Message(content=McpTextContent(type='text', text='meta text', _meta={'source': 'mcp'}))]
@server.prompt()
def image_prompt() -> list[Message]:
"""A prompt template with image content."""
return [
Message(
content=ImageContent(
type='image',
data=base64.b64encode(b'image-bytes').decode('utf-8'),
mimeType='image/jpeg',
annotations=Annotations(audience=['user'], priority=0.8),
)
)
]
@server.prompt()
def audio_prompt() -> list[Message]:
"""A prompt template with audio content."""
return [
Message(
content=AudioContent(
type='audio',
data=base64.b64encode(b'audio-bytes').decode('utf-8'),
mimeType='audio/mpeg',
annotations=Annotations(audience=['assistant'], priority=0.3),
)
)
]
@server.prompt()
def embedded_resource_prompt() -> list[Message]:
"""A prompt template with an embedded text resource."""
return [
Message(
content=EmbeddedResource(
type='resource',
resource=TextResourceContents(
uri=AnyUrl('resource://product_name.txt'),
text='Pydantic AI',
mimeType='text/plain',
),
annotations=Annotations(audience=['user'], priority=0.5),
)
)
]
@pytest.fixture
def run_context() -> RunContext:
return RunContext(
deps=None,
model=TestModel(),
usage=RunUsage(),
prompt=None,
messages=[],
run_step=0,
)
class TestMCPToolsetIntegration:
"""End-to-end coverage against an in-process FastMCP server."""
async def test_lifecycle_exposes_init_state(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
assert toolset.is_running is False
async with toolset:
assert toolset.is_running is True
assert toolset.server_info.name == 'test_server'
assert toolset.capabilities.tools is True
assert toolset.instructions == 'You are an MCP test server.'
assert toolset.is_running is False
async def test_aexit_called_before_aenter_raises(self, fastmcp_server: FastMCP[None]):
"""Calling `__aexit__` before any `__aenter__` should raise — `_running_count` is 0."""
toolset = MCPToolset(fastmcp_server)
with pytest.raises(ValueError, match='called more times than'):
await toolset.__aexit__(None, None, None)
async def test_aexit_called_more_times_than_aenter(self, fastmcp_server: FastMCP[None]):
"""Calling `__aexit__` more times than `__aenter__` should raise."""
toolset = MCPToolset(fastmcp_server)
async with toolset:
pass
with pytest.raises(ValueError, match='called more times than'):
await toolset.__aexit__(None, None, None)
async def test_get_tools_caches_and_lists(self, fastmcp_server: FastMCP[None], run_context: RunContext):
toolset = MCPToolset(fastmcp_server, include_instructions=True)
async with toolset:
tools_first = await toolset.get_tools(run_context)
tools_second = await toolset.get_tools(run_context)
assert {'echo', 'add', 'boom'} <= set(tools_first)
# Second call should hit the cache (covers the cached-return branch).
assert tools_first['echo'].tool_def.description == tools_second['echo'].tool_def.description
async def test_get_instructions_when_enabled(self, fastmcp_server: FastMCP[None], run_context: RunContext):
toolset = MCPToolset(fastmcp_server, include_instructions=True)
async with toolset:
part = await toolset.get_instructions(run_context)
assert part is not None
assert part.content == 'You are an MCP test server.'
assert part.dynamic is False
async def test_get_instructions_returns_none_when_disabled(
self, fastmcp_server: FastMCP[None], run_context: RunContext
):
toolset = MCPToolset(fastmcp_server)
async with toolset:
assert await toolset.get_instructions(run_context) is None
async def test_get_instructions_returns_none_pre_init(self, fastmcp_server: FastMCP[None], run_context: RunContext):
toolset = MCPToolset(fastmcp_server, include_instructions=True)
# Without entering, instructions aren't populated yet.
assert await toolset.get_instructions(run_context) is None
async def test_tools_no_caching_when_disabled(self, fastmcp_server: FastMCP[None], run_context: RunContext):
toolset = MCPToolset(fastmcp_server, cache_tools=False)
async with toolset:
await toolset.get_tools(run_context)
assert toolset._cached_tools is None # pyright: ignore[reportPrivateUsage]
async def test_call_tool_returns_structured_content(self, fastmcp_server: FastMCP[None], run_context: RunContext):
toolset = MCPToolset(fastmcp_server)
async with toolset:
tools = await toolset.get_tools(run_context)
result = await toolset.call_tool('add', {'a': 2, 'b': 3}, run_context, tools['add'])
assert result == {'sum': 5}
async def test_call_tool_returns_text(self, fastmcp_server: FastMCP[None], run_context: RunContext):
toolset = MCPToolset(fastmcp_server)
async with toolset:
tools = await toolset.get_tools(run_context)
result = await toolset.call_tool('echo', {'message': 'hi'}, run_context, tools['echo'])
assert result == 'Echo: hi'
async def test_tool_error_raises_model_retry(self, fastmcp_server: FastMCP[None], run_context: RunContext):
toolset = MCPToolset(fastmcp_server)
async with toolset:
tools = await toolset.get_tools(run_context)
with pytest.raises(ModelRetry):
await toolset.call_tool('boom', {}, run_context, tools['boom'])
async def test_tool_error_raises_tool_error_when_configured(
self, fastmcp_server: FastMCP[None], run_context: RunContext
):
toolset = MCPToolset(fastmcp_server, tool_error_behavior='error')
async with toolset:
tools = await toolset.get_tools(run_context)
with pytest.raises(ToolError):
await toolset.call_tool('boom', {}, run_context, tools['boom'])
@pytest.mark.parametrize(
'leaf_factory',
[
pytest.param(lambda: ToolError('grouped tool error'), id='tool-error'),
pytest.param(lambda: McpError(mcp_types.ErrorData(code=400, message='grouped tool error')), id='mcp-error'),
],
)
async def test_call_tool_unwraps_real_exception_group_to_model_retry(
self, fastmcp_server: FastMCP[None], run_context: RunContext, leaf_factory: Any
):
"""A tool/protocol error that surfaces wrapped in an `ExceptionGroup` is converted to a
recoverable `ModelRetry`, not a fatal crash.
This is a unit test because the wrapping is a timing-dependent race in the MCP client's
anyio task group (an empty-bodied tool error colliding with the session's GET-stream
teardown) that can't be triggered deterministically. Rather than hand-build the group, we
inject a failure at the real escape seam — `self.client.call_tool` — and let a genuine
`anyio` task group produce the `ExceptionGroup`, so its structure matches production.
"""
toolset = MCPToolset(fastmcp_server)
async def call_tool_in_failing_task_group(*args: Any, **kwargs: Any) -> Any:
async def fail() -> None:
raise leaf_factory()
async with anyio.create_task_group() as tg:
tg.start_soon(fail)
async with toolset:
tools = await toolset.get_tools(run_context)
toolset.client.call_tool = call_tool_in_failing_task_group
with pytest.raises(ModelRetry, match='grouped tool error'):
await toolset.call_tool('echo', {'message': 'hi'}, run_context, tools['echo'])
async def test_call_tool_reraises_grouped_errors_it_must_not_convert(
self, fastmcp_server: FastMCP[None], run_context: RunContext
):
"""Groups we must not silently turn into a retry are re-raised unchanged: a group that also
contains a non-tool error, and (with `tool_error_behavior='error'`) any grouped tool error."""
def failing_call_tool(*excs: BaseException) -> Any:
async def call_tool(*args: Any, **kwargs: Any) -> Any:
async def fail(exc: BaseException) -> None:
raise exc
async with anyio.create_task_group() as tg:
for exc in excs:
tg.start_soon(fail, exc)
return call_tool
# A mixed group (tool error + an unrelated error) must propagate, not be swallowed.
retry_toolset = MCPToolset(fastmcp_server)
async with retry_toolset:
tools = await retry_toolset.get_tools(run_context)
retry_toolset.client.call_tool = failing_call_tool(
ToolError('grouped tool error'), ValueError('unrelated failure')
)
with pytest.raises(BaseExceptionGroup):
await retry_toolset.call_tool('echo', {'message': 'hi'}, run_context, tools['echo'])
# With `tool_error_behavior='error'`, even a pure tool-error group propagates unchanged.
error_toolset = MCPToolset(fastmcp_server, tool_error_behavior='error')
async with error_toolset:
tools = await error_toolset.get_tools(run_context)
error_toolset.client.call_tool = failing_call_tool(ToolError('grouped tool error'))
with pytest.raises(BaseExceptionGroup):
await error_toolset.call_tool('echo', {'message': 'hi'}, run_context, tools['echo'])
async def test_process_tool_call_hook_runs(self, fastmcp_server: FastMCP[None], run_context: RunContext):
seen: list[tuple[str, dict[str, Any]]] = []
async def hook(ctx: RunContext[Any], call_tool: Any, name: str, args: dict[str, Any]) -> Any:
seen.append((name, args))
return await call_tool(name, args, metadata=None)
toolset = MCPToolset(fastmcp_server, process_tool_call=hook)
async with toolset:
tools = await toolset.get_tools(run_context)
result = await toolset.call_tool('echo', {'message': 'hi'}, run_context, tools['echo'])
assert result == 'Echo: hi'
assert seen == [('echo', {'message': 'hi'})]
async def test_list_resources_returns_pai_types(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
resources = await toolset.list_resources()
cached = await toolset.list_resources()
assert any(r.name == 'greeting' for r in resources)
assert resources == cached
async def test_list_resources_no_caching_when_disabled(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server, cache_resources=False)
async with toolset:
await toolset.list_resources()
assert toolset._cached_resources is None # pyright: ignore[reportPrivateUsage]
async def test_list_resource_templates(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
templates = await toolset.list_resource_templates()
# The `profile` resource has a `{name}` placeholder so it's a template.
assert any('{name}' in t.uri_template for t in templates)
async def test_read_resource_text(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
content = await toolset.read_resource('resource://greeting.txt')
assert content == 'Hello, world!'
async def test_read_resource_via_resource_object(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
resources = await toolset.list_resources()
greeting = next(r for r in resources if r.name == 'greeting')
content = await toolset.read_resource(greeting)
assert content == 'Hello, world!'
async def test_read_resource_template_instance(self, fastmcp_server: FastMCP[None]):
"""Reading a resource produced from a template (`resource://{name}/profile.json`)."""
toolset = MCPToolset(fastmcp_server)
async with toolset:
content = await toolset.read_resource('resource://alice/profile.json')
assert content == '{"name": "alice"}'
async def test_resource_methods_without_capability(self, fastmcp_server: FastMCP[None]):
"""When the server's `capabilities.resources` is `False`, the methods return empty lists
without round-tripping to the server."""
toolset = MCPToolset(fastmcp_server)
async with toolset:
# Force the capability off to exercise the early-return branches.
toolset._server_capabilities = ServerCapabilities() # pyright: ignore[reportPrivateUsage]
assert await toolset.list_resources() == []
assert await toolset.list_resource_templates() == []
async def test_list_prompts_returns_pai_types(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
prompts = await toolset.list_prompts()
assert prompts == snapshot(
[
Prompt(
name='simple_prompt', description='A simple prompt template.', metadata={'fastmcp': {'tags': []}}
),
Prompt(
name='parameterized_prompt',
description='A prompt template with parameters.',
arguments=[
PromptArgument(
name='name',
description='Provide as a JSON string matching the following schema: {"type":"string"}',
required=True,
),
PromptArgument(
name='topic',
description='Provide as a JSON string matching the following schema: {"type":"string"}',
required=True,
),
],
metadata={'fastmcp': {'tags': []}},
),
Prompt(
name='annotated_text_prompt',
description='A prompt template with annotated text content.',
metadata={'fastmcp': {'tags': []}},
),
Prompt(
name='text_meta_prompt',
description='A prompt template with `_meta` text metadata.',
metadata={'fastmcp': {'tags': []}},
),
Prompt(
name='image_prompt',
description='A prompt template with image content.',
metadata={'fastmcp': {'tags': []}},
),
Prompt(
name='audio_prompt',
description='A prompt template with audio content.',
metadata={'fastmcp': {'tags': []}},
),
Prompt(
name='embedded_resource_prompt',
description='A prompt template with an embedded text resource.',
metadata={'fastmcp': {'tags': []}},
),
]
)
async def test_get_prompt_simple(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
result = await toolset.get_prompt('simple_prompt')
assert result == snapshot(
PromptResult(
messages=[PromptMessage(role='user', content=TextContent(content='This is a simple prompt'))],
description='A simple prompt template.',
)
)
async def test_get_prompt_parameterized(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
result = await toolset.get_prompt('parameterized_prompt', {'name': 'Alice', 'topic': 'AI'})
assert result == snapshot(
PromptResult(
messages=[PromptMessage(role='user', content=TextContent(content="Hello Alice, let's talk about AI!"))],
description='A prompt template with parameters.',
)
)
async def test_list_prompts_caches_when_enabled(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
first = await toolset.list_prompts()
assert toolset._cached_prompts is not None # pyright: ignore[reportPrivateUsage]
second = await toolset.list_prompts()
assert first == second
async def test_list_prompts_no_caching_when_disabled(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server, cache_prompts=False)
async with toolset:
await toolset.list_prompts()
assert toolset._cached_prompts is None # pyright: ignore[reportPrivateUsage]
async def test_prompts_cache_invalidation_on_notification(self, fastmcp_server: FastMCP[None]):
from pydantic_ai.mcp import _build_message_handler # type: ignore[attr-defined]
toolset = MCPToolset(fastmcp_server)
handler = _build_message_handler(toolset, user_handler=None)
toolset._cached_prompts = [] # pyright: ignore[reportPrivateUsage]
await handler(
mcp_types.ServerNotification(
root=mcp_types.PromptListChangedNotification(method='notifications/prompts/list_changed')
)
)
assert toolset._cached_prompts is None # pyright: ignore[reportPrivateUsage]
async def test_prompts_without_capability(self, fastmcp_server: FastMCP[None]):
"""`list_prompts` returns `[]` and `get_prompt` raises `MCPError` when prompts capability is absent."""
toolset = MCPToolset(fastmcp_server)
async with toolset:
toolset._server_capabilities = ServerCapabilities() # pyright: ignore[reportPrivateUsage]
assert await toolset.list_prompts() == []
with pytest.raises(MCPError, match='does not advertise the `prompts` capability') as exc_info:
await toolset.get_prompt('does_not_matter')
assert exc_info.value.code == -32601
async def test_list_prompts_wraps_mcp_error(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
toolset.client.list_prompts = AsyncMock(
side_effect=McpError(mcp_types.ErrorData(code=-32603, message='boom'))
)
with pytest.raises(MCPError, match='boom'):
await toolset.list_prompts()
async def test_get_prompt_wraps_mcp_error(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server)
async with toolset:
with pytest.raises(MCPError, match='Unknown prompt'):
await toolset.get_prompt('does_not_exist')
async def test_map_prompt_content(self, fastmcp_server: FastMCP[None]):
"""`get_prompt` maps every MCP prompt content type to its Pydantic AI equivalent.
Plain `TextContent` without annotations is already covered by `test_get_prompt_simple`,
so this exercises annotated text, image, audio, and embedded resource. `ResourceLink`
prompt content is covered separately in `test_get_prompt_maps_resource_link` because the
in-process FastMCP server serializes resource links to text rather than emitting a
`resource_link` content block.
"""
from pydantic_ai.mcp import EmbeddedResource as PaiEmbeddedResource
from pydantic_ai.messages import BinaryContent, BinaryImage
toolset = MCPToolset(fastmcp_server)
async with toolset:
# TextContent with annotations preserved in metadata
annotated = await toolset.get_prompt('annotated_text_prompt')
assert annotated.messages == snapshot(
[
PromptMessage(
role='user',
content=TextContent(
content='annotated text',
metadata={'mcp_annotations': ResourceAnnotations(audience=['user'], priority=1.0)},
),
)
]
)
# ImageContent → BinaryImage
image = await toolset.get_prompt('image_prompt')
assert image.messages == snapshot(
[
PromptMessage(
role='user',
content=BinaryImage(
data=b'image-bytes',
media_type='image/jpeg',
vendor_metadata={'mcp_annotations': ResourceAnnotations(audience=['user'], priority=0.8)},
),
)
]
)
# AudioContent → BinaryContent
audio = await toolset.get_prompt('audio_prompt')
assert audio.messages == snapshot(
[
PromptMessage(
role='user',
content=BinaryContent(
data=b'audio-bytes',
media_type='audio/mpeg',
vendor_metadata={
'mcp_annotations': ResourceAnnotations(audience=['assistant'], priority=0.3)
},
),
)
]
)
# EmbeddedResource with annotations
embedded = await toolset.get_prompt('embedded_resource_prompt')
assert embedded.messages == snapshot(
[
PromptMessage(
role='user',
content=PaiEmbeddedResource(
uri='resource://product_name.txt',
content='Pydantic AI',
mime_type='text/plain',
annotations=ResourceAnnotations(audience=['user'], priority=0.5),
),
)
]
)
async def test_get_prompt_maps_resource_link(self, fastmcp_server: FastMCP[None]):
"""A `resource_link` prompt content block maps to a Pydantic AI `ResourceLink`.
FastMCP can't emit `resource_link` prompt content (it serializes the link to text), so we
patch the client to return a real MCP `GetPromptResult` carrying one and assert the mapping.
"""
from pydantic_ai.mcp import ResourceLink as PaiResourceLink
toolset = MCPToolset(fastmcp_server)
async with toolset:
toolset.client.get_prompt = AsyncMock(
return_value=mcp_types.GetPromptResult(
description='A prompt template with a resource link.',
messages=[
mcp_types.PromptMessage(
role='user',
content=ResourceLink(
type='resource_link',
uri=AnyUrl('resource://kiwi.jpg'),
name='kiwi-image',
title='Kiwi Image',
description='A photo of a kiwi fruit',
mimeType='image/jpeg',
),
)
],
)
)
result = await toolset.get_prompt('resource_link_prompt')
assert result.messages == snapshot(
[
PromptMessage(
role='user',
content=PaiResourceLink(
uri='resource://kiwi.jpg',
name='kiwi-image',
title='Kiwi Image',
description='A photo of a kiwi fruit',
mime_type='image/jpeg',
),
)
]
)
async def test_map_prompt_content_text_meta(self, fastmcp_server: FastMCP[None]):
"""MCP `_meta` on prompt text is preserved in the mapped content metadata."""
toolset = MCPToolset(fastmcp_server)
async with toolset:
result = await toolset.get_prompt('text_meta_prompt')
[message] = result.messages
content = message.content
assert isinstance(content, TextContent)
assert content.metadata == {'mcp_meta': {'source': 'mcp'}}
async def test_message_handler_ignores_non_list_changed_notifications(self, fastmcp_server: FastMCP[None]):
from pydantic_ai.mcp import _build_message_handler # type: ignore[attr-defined]
toolset = MCPToolset(fastmcp_server)
handler = _build_message_handler(toolset, user_handler=None)
toolset._cached_tools = [] # pyright: ignore[reportPrivateUsage]
# `LoggingMessageNotification` is unrelated to any cache.
await handler(
mcp_types.ServerNotification(
root=mcp_types.LoggingMessageNotification(
method='notifications/message',
params=mcp_types.LoggingMessageNotificationParams(level='info', data='hi'),
)
)
)
assert toolset._cached_tools == [] # pyright: ignore[reportPrivateUsage]
async def test_message_handler_ignores_non_notification_messages(self, fastmcp_server: FastMCP[None]):
from pydantic_ai.mcp import _build_message_handler # type: ignore[attr-defined]
toolset = MCPToolset(fastmcp_server)
handler = _build_message_handler(toolset, user_handler=None)
toolset._cached_tools = [] # pyright: ignore[reportPrivateUsage]
# Exception messages are passed through but shouldn't crash or invalidate caches.
await handler(RuntimeError('transport error'))
assert toolset._cached_tools == [] # pyright: ignore[reportPrivateUsage]
async def test_message_handler_invalidates_caches(self, fastmcp_server: FastMCP[None], run_context: RunContext):
from pydantic_ai.mcp import _build_message_handler # type: ignore[attr-defined]
toolset = MCPToolset(fastmcp_server)
handler = _build_message_handler(toolset, user_handler=None)
toolset._cached_tools = [] # pyright: ignore[reportPrivateUsage]
toolset._cached_resources = [] # pyright: ignore[reportPrivateUsage]
await handler(
mcp_types.ServerNotification(
root=mcp_types.ToolListChangedNotification(method='notifications/tools/list_changed')
)
)
assert toolset._cached_tools is None # pyright: ignore[reportPrivateUsage]
toolset._cached_tools = [] # pyright: ignore[reportPrivateUsage]
await handler(
mcp_types.ServerNotification(
root=mcp_types.ResourceListChangedNotification(method='notifications/resources/list_changed')
)
)
assert toolset._cached_resources is None # pyright: ignore[reportPrivateUsage]
async def test_message_handler_forwards_to_user_handler(self, fastmcp_server: FastMCP[None]):
from pydantic_ai.mcp import _build_message_handler # type: ignore[attr-defined]
seen: list[Any] = []
async def user_handler(message: Any) -> None:
seen.append(message)
toolset = MCPToolset(fastmcp_server)
handler = _build_message_handler(toolset, user_handler=user_handler)
notification = mcp_types.ServerNotification(
root=mcp_types.ToolListChangedNotification(method='notifications/tools/list_changed')
)
await handler(notification)
assert seen == [notification]
async def test_call_tool_returns_image(self, fastmcp_server: FastMCP[None], run_context: RunContext):
from pydantic_ai.messages import BinaryContent
toolset = MCPToolset(fastmcp_server)
async with toolset:
tools = await toolset.get_tools(run_context)
result = await toolset.call_tool('image_tool', {}, run_context, tools['image_tool'])
assert isinstance(result, BinaryContent)
assert result.media_type == 'image/png'
async def test_call_tool_returns_embedded_blob(self, fastmcp_server: FastMCP[None], run_context: RunContext):
from pydantic_ai.messages import BinaryContent
toolset = MCPToolset(fastmcp_server)
async with toolset:
tools = await toolset.get_tools(run_context)
result = await toolset.call_tool('embedded_blob_tool', {}, run_context, tools['embedded_blob_tool'])
assert isinstance(result, BinaryContent)
async def test_call_tool_returns_resource_link_uri(self, fastmcp_server: FastMCP[None], run_context: RunContext):
toolset = MCPToolset(fastmcp_server)
async with toolset:
tools = await toolset.get_tools(run_context)
result = await toolset.call_tool('resource_link_tool', {}, run_context, tools['resource_link_tool'])
# `_map_mcp_tool_result` for ResourceLink returns the URI string.
assert result == 'resource://greeting.txt'
async def test_log_level_is_set_after_aenter(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server, log_level='warning')
async with toolset:
# Server received the logging/setLevel call without raising.
assert toolset.is_running
async def test_label_falls_back_to_repr(self):
toolset = MCPToolset('https://example.com/mcp')
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'})
)
assert tool.max_retries == 1
async def test_direct_call_tool_propagates_error_when_configured(self, fastmcp_server: FastMCP[None]):
toolset = MCPToolset(fastmcp_server, tool_error_behavior='error')
async with toolset:
with pytest.raises(ToolError):
await toolset.direct_call_tool('boom', {})
class TestToolResultMapping:
"""Direct unit tests for `_map_mcp_tool_result` — easier than crafting a server response
that bypasses FastMCP's `structured_content` shortcut."""
def test_text_content_returns_str(self):
from pydantic_ai.mcp import _map_mcp_tool_result # type: ignore[attr-defined]
out = _map_mcp_tool_result(mcp_types.TextContent(type='text', text='hello'))
assert out == 'hello'
def test_text_content_with_json_object_is_parsed(self):
from pydantic_ai.mcp import _map_mcp_tool_result # type: ignore[attr-defined]
out = _map_mcp_tool_result(mcp_types.TextContent(type='text', text='{"a": 1}'))
assert out == {'a': 1}
def test_text_content_with_json_array_is_parsed(self):
from pydantic_ai.mcp import _map_mcp_tool_result # type: ignore[attr-defined]
out = _map_mcp_tool_result(mcp_types.TextContent(type='text', text='[1, 2, 3]'))
assert out == [1, 2, 3]
def test_text_content_with_invalid_json_falls_back_to_text(self):
from pydantic_ai.mcp import _map_mcp_tool_result # type: ignore[attr-defined]
# Starts with `{` but isn't valid JSON.
out = _map_mcp_tool_result(mcp_types.TextContent(type='text', text='{not valid'))
assert out == '{not valid'
class TestSamplingHandler:
async def test_sampling_handler_round_trip(self):
"""Drive the sampling handler built from `sampling_model=` to cover its body."""
from pydantic_ai.mcp import _build_sampling_handler # type: ignore[attr-defined]
model = TestModel()
handler = _build_sampling_handler(model)
params = mcp_types.CreateMessageRequestParams(
messages=[mcp_types.SamplingMessage(role='user', content=mcp_types.TextContent(type='text', text='hi'))],
maxTokens=42,
temperature=0.5,
stopSequences=['STOP'],
)
result = await handler([], params, None) # type: ignore[arg-type, misc]
assert isinstance(result, mcp_types.CreateMessageResult)
assert result.model == model.model_name
class TestSamplingMessageMapping:
"""Cover the mapping helpers in `pydantic_ai._mcp` that translate MCP sampling messages
to/from PAI message parts. Exercised via the sampling handler that `MCPToolset(sampling_model=...)` installs."""
async def test_map_handles_image_audio_and_role_transitions(self):
from pydantic_ai import _mcp as _mcp_helpers
params = mcp_types.CreateMessageRequestParams(
messages=[
mcp_types.SamplingMessage(role='user', content=mcp_types.TextContent(type='text', text='hello')),
mcp_types.SamplingMessage(role='assistant', content=mcp_types.TextContent(type='text', text='hi back')),
mcp_types.SamplingMessage(
role='user',
content=mcp_types.ImageContent(
type='image',
data=base64.b64encode(b'fake').decode(),
mimeType='image/png',
),
),
mcp_types.SamplingMessage(
role='user',
content=mcp_types.AudioContent(
type='audio',
data=base64.b64encode(b'fake').decode(),
mimeType='audio/wav',
),
),
mcp_types.SamplingMessage(role='assistant', content=mcp_types.TextContent(type='text', text='final')),
],
systemPrompt='you are helpful',
maxTokens=10,
)
pai_messages = _mcp_helpers.map_from_mcp_params(params)
# Should alternate Request/Response, with the trailing assistant becoming the final ModelResponse.
kinds = [type(m).__name__ for m in pai_messages]
assert kinds == ['ModelRequest', 'ModelResponse', 'ModelRequest', 'ModelResponse']
async def test_map_rejects_unsupported_content_types(self):
from pydantic_ai import _mcp as _mcp_helpers
list_content_params = mcp_types.CreateMessageRequestParams(
messages=[
mcp_types.SamplingMessage(role='user', content=[]),
],
maxTokens=10,
)
with pytest.raises(NotImplementedError, match='list content'):
_mcp_helpers.map_from_mcp_params(list_content_params)
# `ToolUseContent` / `ToolResultContent` from the user side aren't legal sampling input.
tool_use_params = mcp_types.CreateMessageRequestParams(
messages=[
mcp_types.SamplingMessage(
role='user',
content=mcp_types.ToolUseContent(type='tool_use', id='t', name='foo', input={}),
),
],
maxTokens=10,
)
with pytest.raises(NotImplementedError, match='cannot be used as user content'):
_mcp_helpers.map_from_mcp_params(tool_use_params)
# Audio sampling responses are also explicitly unsupported.
audio_response_params = mcp_types.CreateMessageRequestParams(
messages=[
mcp_types.SamplingMessage(
role='assistant',
content=mcp_types.AudioContent(
type='audio',
data=base64.b64encode(b'fake').decode(),
mimeType='audio/wav',
),
),
],
maxTokens=10,
)
with pytest.raises(NotImplementedError):
_mcp_helpers.map_from_sampling_content(audio_response_params.messages[0].content) # type: ignore[arg-type]
async def test_map_handles_consecutive_assistant_messages(self):
"""Two assistant messages in a row append into the same `ModelResponse` (no intervening request)."""
from pydantic_ai import _mcp as _mcp_helpers
params = mcp_types.CreateMessageRequestParams(
messages=[
mcp_types.SamplingMessage(role='assistant', content=mcp_types.TextContent(type='text', text='one')),
mcp_types.SamplingMessage(role='assistant', content=mcp_types.TextContent(type='text', text='two')),
],
maxTokens=10,
)
pai_messages = _mcp_helpers.map_from_mcp_params(params)
assert [type(m).__name__ for m in pai_messages] == ['ModelResponse']
async def test_map_from_model_response_skips_thinking_and_rejects_unknown(self):
from pydantic_ai import _mcp as _mcp_helpers
from pydantic_ai.exceptions import UnexpectedModelBehavior
from pydantic_ai.messages import ModelResponse, TextPart, ThinkingPart, ToolCallPart
# `ThinkingPart` is silently skipped, leaving the text content.
result = _mcp_helpers.map_from_model_response(
ModelResponse(parts=[ThinkingPart(content='hidden'), TextPart(content='visible')])
)
assert result.text == 'visible'
# Unsupported parts (e.g. tool calls) raise a clear error.
with pytest.raises(UnexpectedModelBehavior):
_mcp_helpers.map_from_model_response(ModelResponse(parts=[ToolCallPart(tool_name='foo', args='{}')]))
class TestResourceMethodErrorPaths:
async def test_list_resources_wraps_mcp_error(self, fastmcp_server: FastMCP[None]):
"""Server errors from `list_resources` are wrapped in `MCPError`."""
from unittest.mock import AsyncMock
from mcp.shared.exceptions import McpError
toolset = MCPToolset(fastmcp_server)
async with toolset:
toolset.client.list_resources = AsyncMock(
side_effect=McpError(mcp_types.ErrorData(code=-32603, message='boom'))
)
with pytest.raises(MCPError, match='boom'):
await toolset.list_resources()
async def test_list_resource_templates_wraps_mcp_error(self, fastmcp_server: FastMCP[None]):
from unittest.mock import AsyncMock
from mcp.shared.exceptions import McpError
toolset = MCPToolset(fastmcp_server)
async with toolset:
toolset.client.list_resource_templates = AsyncMock(
side_effect=McpError(mcp_types.ErrorData(code=-32603, message='boom'))
)
with pytest.raises(MCPError, match='boom'):
await toolset.list_resource_templates()
async def test_read_resource_wraps_mcp_error(self, fastmcp_server: FastMCP[None]):
from unittest.mock import AsyncMock
from mcp.shared.exceptions import McpError
toolset = MCPToolset(fastmcp_server)
async with toolset:
toolset.client.read_resource = AsyncMock(
side_effect=McpError(mcp_types.ErrorData(code=-32002, message='not found'))
)
with pytest.raises(MCPError, match='not found'):
await toolset.read_resource('resource://missing')
class TestLoadMCPToolsets:
async def test_loads_toolsets_from_config_without_env(self):
"""Stdio entries without an `env` field also produce valid toolsets."""
config = {
'mcpServers': {
'alpha': {'command': 'python', 'args': ['-m', 'tests.mcp_server']},
}
}
with TemporaryDirectory() as tmp:
config_path = Path(tmp) / 'mcp.json'
config_path.write_text(json.dumps(config))
toolsets = load_mcp_toolsets(config_path)
assert len(toolsets) == 1
async def test_loads_toolsets_from_config_with_prefix(self):
config = {
'mcpServers': {
'alpha': {'command': 'python', 'args': ['-m', 'tests.mcp_server'], 'env': {'FOO': 'bar'}},
}
}
with TemporaryDirectory() as tmp:
config_path = Path(tmp) / 'mcp.json'
config_path.write_text(json.dumps(config))
toolsets = load_mcp_toolsets(config_path)
# Single server entry, wrapped with `.prefixed('alpha')`.
assert len(toolsets) == 1
# The wrapped toolset is a `PrefixedToolset`, not an `MCPToolset` directly.
from pydantic_ai.toolsets.prefixed import PrefixedToolset
assert isinstance(toolsets[0], PrefixedToolset)
assert isinstance(toolsets[0].wrapped, MCPToolset)
async def test_load_mcp_toolsets_missing_file_raises(self):
with pytest.raises(FileNotFoundError):
load_mcp_toolsets('/nonexistent/path/to/config.json')
async def test_load_mcp_toolsets_http_entry(self):
config = {
'mcpServers': {
'beta': {'url': 'http://localhost:8000/mcp', 'headers': {'X-Key': 'foo'}},
}
}
with TemporaryDirectory() as tmp:
config_path = Path(tmp) / 'mcp.json'
config_path.write_text(json.dumps(config))
toolsets = load_mcp_toolsets(config_path)
from pydantic_ai.toolsets.prefixed import PrefixedToolset
assert len(toolsets) == 1
assert isinstance(toolsets[0], PrefixedToolset)
wrapped = toolsets[0].wrapped
assert isinstance(wrapped, MCPToolset)
# Headers flowed through to the FastMCP transport.
assert isinstance(wrapped.client.transport, StreamableHttpTransport)
assert wrapped.client.transport.headers == {'X-Key': 'foo'}
async def test_load_mcp_toolsets_expands_env_vars(self, monkeypatch: pytest.MonkeyPatch):
"""`${VAR_NAME}` references in the config are resolved from `os.environ`; default-syntax
(`${VAR_NAME:-fallback}`) returns the fallback when unset; missing required vars raise."""
monkeypatch.setenv('MCP_TEST_TOKEN', 'secret-value')
config = {
'mcpServers': {
'alpha': {
'url': 'https://${MCP_TEST_HOST:-localhost:8000}/mcp',
'headers': {'Authorization': 'Bearer ${MCP_TEST_TOKEN}', 'X-Extras': ['${MCP_TEST_TOKEN}']},
},
}
}
with TemporaryDirectory() as tmp:
config_path = Path(tmp) / 'mcp.json'
config_path.write_text(json.dumps(config))
toolsets = load_mcp_toolsets(config_path)
wrapped = toolsets[0].wrapped # type: ignore[attr-defined]
assert isinstance(wrapped, MCPToolset)
assert isinstance(wrapped.client.transport, StreamableHttpTransport)
assert wrapped.client.transport.headers == {
'Authorization': 'Bearer secret-value',
'X-Extras': ['secret-value'],
}
assert str(wrapped.client.transport.url) == 'https://localhost:8000/mcp'
async def test_load_mcp_toolsets_undefined_env_var_raises(self):
"""A `${VAR}` reference without a default and not set in the environment raises a clear `ValueError`."""
config = {'mcpServers': {'alpha': {'url': 'https://${MCP_TEST_UNDEFINED}/mcp'}}}
with TemporaryDirectory() as tmp:
config_path = Path(tmp) / 'mcp.json'
config_path.write_text(json.dumps(config))
with pytest.raises(ValueError, match=r'\$\{MCP_TEST_UNDEFINED\} is not defined'):
load_mcp_toolsets(config_path)
async def test_load_mcp_toolsets_rejects_non_object_root(self):
"""The config root must be a JSON object; a list / scalar at the root raises a descriptive error."""
with TemporaryDirectory() as tmp:
config_path = Path(tmp) / 'mcp.json'
config_path.write_text(json.dumps(['not an object']))
with pytest.raises(ValueError, match='Expected JSON object at root'):
load_mcp_toolsets(config_path)
async def test_load_mcp_toolsets_rejects_missing_mcp_servers_key(self):
"""The config must have an `mcpServers` object."""
with TemporaryDirectory() as tmp:
config_path = Path(tmp) / 'mcp.json'
config_path.write_text(json.dumps({'someOtherKey': {}}))
with pytest.raises(ValueError, match='Expected `mcpServers` object'):
load_mcp_toolsets(config_path)
async def test_load_mcp_toolsets_rejects_invalid_server_entry(self):
"""A server entry missing both `command` and `url` raises a clear `ValueError`."""
config = {'mcpServers': {'alpha': {'something': 'else'}}}
with TemporaryDirectory() as tmp:
config_path = Path(tmp) / 'mcp.json'
config_path.write_text(json.dumps(config))
with pytest.raises(ValueError, match=r"MCP server config 'alpha' must have either"):
load_mcp_toolsets(config_path)
async def test_load_mcp_toolsets_passes_primitive_values_through_env_expansion(self):
"""Non-string/dict/list values (ints, bools, null) in the config flow through
`_expand_env_vars` unchanged."""
config = {
'mcpServers': {
'alpha': {
'command': 'python',
'args': ['-m', 'tests.mcp_server'],
# Primitive values: `_expand_env_vars` should return these as-is.
'startup_timeout': 30,
'enable_telemetry': True,
'log_file': None,
},
}
}
with TemporaryDirectory() as tmp:
config_path = Path(tmp) / 'mcp.json'
config_path.write_text(json.dumps(config))
toolsets = load_mcp_toolsets(config_path)
assert len(toolsets) == 1
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`
`timeout` carries the read timeout instead)."""
MCPToolset('https://example.com/mcp', headers={'X-Key': 'foo'})
deprecation_messages = [str(w.message) for w in recwarn if 'sse_read_timeout' in str(w.message)]
assert deprecation_messages == [], deprecation_messages
class TestMCPToolsetBackgroundTasks:
"""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]:
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'
@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: bool` so downstream capabilities can target task-augmented tools."""
toolset = MCPToolset(task_server)
async with toolset:
tools = await toolset.get_tools(run_context)
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_routes_through_task_path(
self, task_server: FastMCP[None], run_context: RunContext[None]
) -> None:
"""`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)
result = await toolset.call_tool('task_required_tool', {}, run_context, tools['task_required_tool'])
assert result == 'task_required_completed'
async def test_optional_tool_routes_through_task_path(
self, task_server: FastMCP[None], run_context: RunContext[None]
) -> None:
"""`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)
result = await toolset.call_tool('task_optional_tool', {}, run_context, tools['task_optional_tool'])
assert result == 'task_optional_completed'
async def test_plain_tool_stays_on_sync_path(
self, task_server: FastMCP[None], run_context: RunContext[None]
) -> None:
"""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)
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)` 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)
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)
result = await toolset.call_tool('task_required_tool', {}, run_context, tools['task_required_tool'])
assert result == 'task_required_completed'