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
1440 lines
64 KiB
Python
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'
|