Skip to content

Commit 1b3e0fe

Browse files
committed
simplification
1 parent 34d36b9 commit 1b3e0fe

File tree

3 files changed

+48
-95
lines changed

3 files changed

+48
-95
lines changed

docs/mcp/fastmcp-client.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pip/uv-add "pydantic-ai-slim[fastmcp]"
1919
A `FastMCPToolset` can then be created from:
2020

2121
- A FastMCP Server: `#!python FastMCPToolset(fastmcp.FastMCP('my_server'))`
22-
- A FastMCP Client: `#!python FastMCPToolset(client=fastmcp.Client(...))`
22+
- A FastMCP Client: `#!python FastMCPToolset(fastmcp.Client(...))`
2323
- A FastMCP Transport: `#!python FastMCPToolset(fastmcp.StdioTransport(command='uvx', args=['mcp-run-python', 'stdio']))`
2424
- A Streamable HTTP URL: `#!python FastMCPToolset('http://localhost:8000/mcp')`
2525
- An HTTP SSE URL: `#!python FastMCPToolset('http://localhost:8000/sse')`
@@ -85,4 +85,4 @@ toolset = FastMCPToolset(mcp_config)
8585
agent = Agent('openai:gpt-5', toolsets=[toolset])
8686
```
8787

88-
_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
88+
_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_

pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py

Lines changed: 32 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,12 @@
33
import base64
44
from asyncio import Lock
55
from contextlib import AsyncExitStack
6-
from dataclasses import KW_ONLY, dataclass, field
6+
from dataclasses import KW_ONLY, dataclass
77
from pathlib import Path
8-
from typing import TYPE_CHECKING, Any, Literal, overload
8+
from typing import TYPE_CHECKING, Any, Literal
99

10-
from fastmcp.client.transports import ClientTransport
11-
from fastmcp.mcp_config import MCPConfig
12-
from fastmcp.server import FastMCP
13-
from mcp.server.fastmcp import FastMCP as FastMCP1Server
14-
from mcp.types import BlobResourceContents, EmbeddedResource, ResourceLink
1510
from pydantic import AnyUrl
16-
from typing_extensions import Self
11+
from typing_extensions import Self, assert_never
1712

1813
from pydantic_ai import messages
1914
from pydantic_ai.exceptions import ModelRetry
@@ -23,12 +18,20 @@
2318

2419
try:
2520
from fastmcp.client import Client
21+
from fastmcp.client.transports import ClientTransport
2622
from fastmcp.exceptions import ToolError
23+
from fastmcp.mcp_config import MCPConfig
24+
from fastmcp.server import FastMCP
25+
from mcp.server.fastmcp import FastMCP as FastMCP1Server
2726
from mcp.types import (
2827
AudioContent,
28+
BlobResourceContents,
2929
ContentBlock,
30+
EmbeddedResource,
3031
ImageContent,
32+
ResourceLink,
3133
TextContent,
34+
TextResourceContents,
3235
Tool as MCPTool,
3336
)
3437

@@ -52,7 +55,7 @@
5255
UNKNOWN_BINARY_MEDIA_TYPE = 'application/octet-stream'
5356

5457

55-
@dataclass
58+
@dataclass(init=False)
5659
class FastMCPToolset(AbstractToolset[AgentDepsT]):
5760
"""A FastMCP Toolset that uses the FastMCP Client to call tools from a local or remote MCP Server.
5861
@@ -62,73 +65,38 @@ class FastMCPToolset(AbstractToolset[AgentDepsT]):
6265
"""
6366

6467
client: Client[Any]
65-
"""The FastMCP transport to use. This can be a local or remote MCP Server configuration, a transport string, or a FastMCP Client."""
68+
"""The FastMCP client to use."""
6669

6770
_: KW_ONLY
6871

69-
tool_error_behavior: Literal['model_retry', 'error'] = field(default='error')
72+
tool_error_behavior: Literal['model_retry', 'error']
7073
"""The behavior to take when a tool error occurs."""
7174

72-
max_retries: int = field(default=2)
75+
max_retries: int
7376
"""The maximum number of retries to attempt if a tool call fails."""
7477

75-
_id: str | None = field(default=None)
78+
_id: str | None
7679

77-
@overload
7880
def __init__(
7981
self,
80-
*,
81-
client: Client[Any],
82-
max_retries: int = 2,
83-
tool_error_behavior: Literal['model_retry', 'error'] = 'error',
84-
id: str | None = None,
85-
) -> None: ...
86-
87-
@overload
88-
def __init__(
89-
self,
90-
transport: ClientTransport
91-
| FastMCP
92-
| FastMCP1Server
93-
| AnyUrl
94-
| Path
95-
| MCPConfig
96-
| dict[str, Any]
97-
| str
98-
| None = None,
99-
*,
100-
max_retries: int = 2,
101-
tool_error_behavior: Literal['model_retry', 'error'] = 'error',
102-
id: str | None = None,
103-
) -> None: ...
104-
105-
def __init__(
106-
self,
107-
transport: ClientTransport
82+
client: Client[Any]
83+
| ClientTransport
10884
| FastMCP
10985
| FastMCP1Server
11086
| AnyUrl
11187
| Path
11288
| MCPConfig
11389
| dict[str, Any]
114-
| str
115-
| None = None,
90+
| str,
11691
*,
117-
client: Client[Any] | None = None,
118-
max_retries: int = 2,
92+
max_retries: int = 1,
11993
tool_error_behavior: Literal['model_retry', 'error'] = 'model_retry',
12094
id: str | None = None,
12195
) -> None:
122-
if not client and not transport:
123-
raise ValueError('Either client or transport must be provided')
124-
125-
if client and transport:
126-
raise ValueError('Either client or transport must be provided, not both')
127-
128-
if client:
96+
if isinstance(client, Client):
12997
self.client = client
13098
else:
131-
self.client = Client[Any](transport=transport)
99+
self.client = Client[Any](transport=client)
132100

133101
self._id = id
134102
self.max_retries = max_retries
@@ -144,7 +112,7 @@ def id(self) -> str | None:
144112

145113
async def __aenter__(self) -> Self:
146114
async with self._enter_lock:
147-
if self._running_count == 0 and self.client:
115+
if self._running_count == 0:
148116
self._exit_stack = AsyncExitStack()
149117
await self._exit_stack.enter_async_context(self.client)
150118

@@ -226,25 +194,22 @@ def _map_fastmcp_tool_results(parts: list[ContentBlock]) -> list[FastMCPToolResu
226194
def _map_fastmcp_tool_result(part: ContentBlock) -> FastMCPToolResult:
227195
if isinstance(part, TextContent):
228196
return part.text
229-
230-
if isinstance(part, ImageContent | AudioContent):
197+
elif isinstance(part, ImageContent | AudioContent):
231198
return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType)
232-
233-
if isinstance(part, EmbeddedResource):
199+
elif isinstance(part, EmbeddedResource):
234200
if isinstance(part.resource, BlobResourceContents):
235201
return messages.BinaryContent(
236202
data=base64.b64decode(part.resource.blob),
237203
media_type=part.resource.mimeType or UNKNOWN_BINARY_MEDIA_TYPE,
238204
)
239-
240-
# If not a BlobResourceContents, it's a TextResourceContents
241-
return part.resource.text
242-
243-
if isinstance(part, ResourceLink):
205+
elif isinstance(part.resource, TextResourceContents):
206+
return part.resource.text
207+
else:
208+
assert_never(part.resource)
209+
elif isinstance(part, ResourceLink):
244210
# ResourceLink is not yet supported by the FastMCP toolset as reading resources is not yet supported.
245211
raise NotImplementedError(
246212
'ResourceLink is not supported by the FastMCP toolset as reading resources is not yet supported.'
247213
)
248-
249-
msg = f'Unsupported/Unknown content block type: {type(part)}' # pragma: no cover
250-
raise ValueError(msg) # pragma: no cover)
214+
else:
215+
assert_never(part)

tests/test_fastmcp.py

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -158,42 +158,30 @@ class TestFastMCPToolsetInitialization:
158158

159159
async def test_init_with_client(self, fastmcp_client: Client[FastMCPTransport]):
160160
"""Test initialization with a FastMCP client."""
161-
toolset = FastMCPToolset(client=fastmcp_client)
161+
toolset = FastMCPToolset(fastmcp_client)
162162

163163
# Test that the client is accessible via the property
164164
assert toolset.id is None
165165

166166
async def test_init_with_id(self, fastmcp_client: Client[FastMCPTransport]):
167167
"""Test initialization with an id."""
168-
toolset = FastMCPToolset(client=fastmcp_client, id='test_id')
168+
toolset = FastMCPToolset(fastmcp_client, id='test_id')
169169

170170
# Test that the client is accessible via the property
171171
assert toolset.id == 'test_id'
172172

173173
async def test_init_with_custom_retries_and_error_behavior(self, fastmcp_client: Client[FastMCPTransport]):
174174
"""Test initialization with custom retries and error behavior."""
175-
toolset = FastMCPToolset(client=fastmcp_client, max_retries=5, tool_error_behavior='model_retry')
175+
toolset = FastMCPToolset(fastmcp_client, max_retries=5, tool_error_behavior='model_retry')
176176

177177
# Test that the toolset was created successfully
178178
assert toolset.client is fastmcp_client
179179

180180
async def test_id_property(self, fastmcp_client: Client[FastMCPTransport]):
181181
"""Test that the id property returns None."""
182-
toolset = FastMCPToolset(client=fastmcp_client)
182+
toolset = FastMCPToolset(fastmcp_client)
183183
assert toolset.id is None
184184

185-
async def test_init_without_client_or_transport(self):
186-
"""Test initialization without a client or transport."""
187-
with pytest.raises(ValueError, match='Either client or transport must be provided'):
188-
FastMCPToolset()
189-
190-
async def test_init_with_client_and_transport(self):
191-
"""Test initialization with a client and transport."""
192-
with pytest.raises(ValueError, match='Either client or transport must be provided, not both'):
193-
tmp_server = FastMCP('tmp_server')
194-
client = Client(transport=tmp_server)
195-
FastMCPToolset(client=client, transport=tmp_server) # pyright: ignore[reportCallIssue]
196-
197185

198186
class TestFastMCPToolsetContextManagement:
199187
"""Test FastMCP Toolset context management."""
@@ -202,7 +190,7 @@ async def test_context_manager_single_enter_exit(
202190
self, fastmcp_client: Client[FastMCPTransport], run_context: RunContext[None]
203191
):
204192
"""Test single enter/exit cycle."""
205-
toolset = FastMCPToolset(client=fastmcp_client)
193+
toolset = FastMCPToolset(fastmcp_client)
206194

207195
async with toolset:
208196
# Test that we can get tools when the context is active
@@ -216,7 +204,7 @@ async def test_context_manager_no_enter(
216204
self, fastmcp_client: Client[FastMCPTransport], run_context: RunContext[None]
217205
):
218206
"""Test no enter/exit cycle."""
219-
toolset = FastMCPToolset(client=fastmcp_client)
207+
toolset = FastMCPToolset(fastmcp_client)
220208

221209
# Test that we can get tools when the context is not active
222210
tools = await toolset.get_tools(run_context)
@@ -227,7 +215,7 @@ async def test_context_manager_nested_enter_exit(
227215
self, fastmcp_client: Client[FastMCPTransport], run_context: RunContext[None]
228216
):
229217
"""Test nested enter/exit cycles."""
230-
toolset = FastMCPToolset(client=fastmcp_client)
218+
toolset = FastMCPToolset(fastmcp_client)
231219

232220
async with toolset:
233221
tools1 = await toolset.get_tools(run_context)
@@ -248,7 +236,7 @@ async def test_get_tools(
248236
run_context: RunContext[None],
249237
):
250238
"""Test getting tools from the FastMCP client."""
251-
toolset = FastMCPToolset(client=fastmcp_client)
239+
toolset = FastMCPToolset(fastmcp_client)
252240

253241
async with toolset:
254242
tools = await toolset.get_tools(run_context)
@@ -275,7 +263,7 @@ async def test_get_tools(
275263
assert test_tool.tool_def.name == 'test_tool'
276264
assert test_tool.tool_def.description is not None
277265
assert 'test tool that returns a formatted string' in test_tool.tool_def.description
278-
assert test_tool.max_retries == 2
266+
assert test_tool.max_retries == 1
279267
assert test_tool.toolset is toolset
280268

281269
# Check that the tool has proper schema
@@ -288,7 +276,7 @@ async def test_get_tools_with_empty_server(self, run_context: RunContext[None]):
288276
"""Test getting tools from an empty FastMCP server."""
289277
empty_server = FastMCP('empty_server')
290278
empty_client = Client(transport=empty_server)
291-
toolset = FastMCPToolset(client=empty_client)
279+
toolset = FastMCPToolset(empty_client)
292280

293281
async with toolset:
294282
tools = await toolset.get_tools(run_context)
@@ -301,7 +289,7 @@ class TestFastMCPToolsetToolCalling:
301289
@pytest.fixture
302290
async def fastmcp_toolset(self, fastmcp_client: Client[FastMCPTransport]) -> FastMCPToolset[None]:
303291
"""Create a FastMCP Toolset."""
304-
return FastMCPToolset(client=fastmcp_client)
292+
return FastMCPToolset(fastmcp_client)
305293

306294
async def test_call_tool_success(
307295
self,
@@ -491,7 +479,7 @@ async def test_call_tool_with_error_behavior_raise(
491479
run_context: RunContext[None],
492480
):
493481
"""Test tool call with error behavior set to raise."""
494-
toolset = FastMCPToolset(client=fastmcp_client, tool_error_behavior='error')
482+
toolset = FastMCPToolset(fastmcp_client, tool_error_behavior='error')
495483

496484
async with toolset:
497485
tools = await toolset.get_tools(run_context)
@@ -506,7 +494,7 @@ async def test_call_tool_with_error_behavior_model_retry(
506494
run_context: RunContext[None],
507495
):
508496
"""Test tool call with error behavior set to model retry."""
509-
toolset = FastMCPToolset(client=fastmcp_client, tool_error_behavior='model_retry')
497+
toolset = FastMCPToolset(fastmcp_client, tool_error_behavior='model_retry')
510498

511499
async with toolset:
512500
tools = await toolset.get_tools(run_context)
@@ -577,7 +565,7 @@ async def test_transports(self):
577565
@pytest.mark.parametrize(
578566
'invalid_transport', ['tomato_is_not_a_valid_transport', '/path/to/server.ini', 'ftp://localhost']
579567
)
580-
async def test_invalid_transports_uninferrable(self, invalid_transport: str | None):
568+
async def test_invalid_transports_uninferrable(self, invalid_transport: str):
581569
"""Test creating toolset from invalid transports."""
582570
with pytest.raises(ValueError, match='Could not infer a valid transport from:'):
583571
FastMCPToolset(invalid_transport)

0 commit comments

Comments
 (0)