Skip to content

Commit f88dc59

Browse files
committed
Alternative Universe
1 parent 80a7284 commit f88dc59

File tree

12 files changed

+214
-141
lines changed

12 files changed

+214
-141
lines changed

docs/mcp/client.md

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Examples of all three are shown below; [mcp-run-python](run-python.md) is used a
2626

2727
Each MCP server instance is a [toolset](../toolsets.md) and can be registered with an [`Agent`][pydantic_ai.Agent] using the `toolsets` argument.
2828

29-
You can use the [`async with agent`][pydantic_ai.Agent.__aenter__] context manager to open and close connections to all registered servers (and in the case of stdio servers, start and stop the subprocesses) around the context where they'll be used in agent runs. You can also use [`async with server`][pydantic_ai.mcp.MCPServer.__aenter__] to manage the connection or subprocess of a specific server, for example if you'd like to use it with multiple agents. If you don't explicitly enter one of these context managers to set up the server, this will be done automatically when it's needed (e.g. to list the available tools or call a specific tool), but it's more efficient to do so around the entire context where you expect the servers to be used.
29+
You can use the [`async with agent.setup()`][pydantic_ai.Agent.__aenter__] context manager to open and close connections to all registered servers (and in the case of stdio servers, start and stop the subprocesses) around the context where theyll be used in agent runs. You can also use [`async with server`][pydantic_ai.mcp.MCPServer.__aenter__] to manage the connection or subprocess of a specific server, for example if youd like to use it with multiple agents. If you dont explicitly enter one of these context managers to set up the server, this will be done automatically when its needed (e.g. to list the available tools or call a specific tool), but its more efficient to do so around the entire context where you expect the servers to be used.
3030

3131
### Streamable HTTP Client
3232

@@ -61,7 +61,7 @@ server = MCPServerStreamableHTTP('http://localhost:8000/mcp') # (1)!
6161
agent = Agent('openai:gpt-4o', toolsets=[server]) # (2)!
6262

6363
async def main():
64-
async with agent: # (3)!
64+
async with agent.setup(): # (3)!
6565
result = await agent.run('How many days between 2000-01-01 and 2025-03-18?')
6666
print(result.output)
6767
#> There are 9,208 days between January 1, 2000, and March 18, 2025.
@@ -71,18 +71,18 @@ async def main():
7171
2. Create an agent with the MCP server attached.
7272
3. Create a client session to connect to the server.
7373

74-
_(This example is complete, it can be run "as is" with Python 3.10+ — you'll need to add `asyncio.run(main())` to run `main`)_
74+
_(This example is complete, it can be run as is with Python 3.10+ — youll need to add `asyncio.run(main())` to run `main`)_
7575

76-
**What's happening here?**
76+
**Whats happening here?**
7777

78-
- The model is receiving the prompt "how many days between 2000-01-01 and 2025-03-18?"
79-
- The model decides "Oh, I've got this `run_python_code` tool, that will be a good way to answer this question", and writes some python code to calculate the answer.
78+
- The model is receiving the prompt how many days between 2000-01-01 and 2025-03-18?
79+
- The model decides Oh, Ive got this `run_python_code` tool, that will be a good way to answer this question, and writes some python code to calculate the answer.
8080
- The model returns a tool call
8181
- Pydantic AI sends the tool call to the MCP server using the SSE transport
8282
- The model is called again with the return value of running the code
8383
- The model returns the final answer
8484

85-
You can visualise this clearly, and even see the code that's run by adding three lines of code to instrument the example with [logfire](https://logfire.pydantic.dev/docs):
85+
You can visualise this clearly, and even see the code thats run by adding three lines of code to instrument the example with [logfire](https://logfire.pydantic.dev/docs):
8686

8787
```python {title="mcp_sse_client_logfire.py" test="skip"}
8888
import logfire
@@ -102,7 +102,7 @@ Will display as follows:
102102
!!! note
103103
[`MCPServerSSE`][pydantic_ai.mcp.MCPServerSSE] requires an MCP server to be running and accepting HTTP connections before running the agent. Running the server is not managed by Pydantic AI.
104104

105-
The name "HTTP" is used since this implementation will be adapted in future to use the new
105+
The name HTTP is used since this implementation will be adapted in future to use the new
106106
[Streamable HTTP](https://github.com/modelcontextprotocol/specification/pull/206) currently in development.
107107

108108
Before creating the SSE client, we need to run the server (docs [here](run-python.md)):
@@ -122,7 +122,7 @@ agent = Agent('openai:gpt-4o', toolsets=[server]) # (2)!
122122

123123

124124
async def main():
125-
async with agent: # (3)!
125+
async with agent.setup(): # (3)!
126126
result = await agent.run('How many days between 2000-01-01 and 2025-03-18?')
127127
print(result.output)
128128
#> There are 9,208 days between January 1, 2000, and March 18, 2025.
@@ -132,11 +132,11 @@ async def main():
132132
2. Create an agent with the MCP server attached.
133133
3. Create a client session to connect to the server.
134134

135-
_(This example is complete, it can be run "as is" with Python 3.10+ — you'll need to add `asyncio.run(main())` to run `main`)_
135+
_(This example is complete, it can be run as is with Python 3.10+ — youll need to add `asyncio.run(main())` to run `main`)_
136136

137-
### MCP "stdio" Server
137+
### MCP stdio Server
138138

139-
The other transport offered by MCP is the [stdio transport](https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#stdio) where the server is run as a subprocess and communicates with the client over `stdin` and `stdout`. In this case, you'd use the [`MCPServerStdio`][pydantic_ai.mcp.MCPServerStdio] class.
139+
The other transport offered by MCP is the [stdio transport](https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#stdio) where the server is run as a subprocess and communicates with the client over `stdin` and `stdout`. In this case, youd use the [`MCPServerStdio`][pydantic_ai.mcp.MCPServerStdio] class.
140140

141141
```python {title="mcp_stdio_client.py" py="3.10"}
142142
from pydantic_ai import Agent
@@ -158,7 +158,7 @@ agent = Agent('openai:gpt-4o', toolsets=[server])
158158

159159

160160
async def main():
161-
async with agent:
161+
async with agent.setup():
162162
result = await agent.run('How many days between 2000-01-01 and 2025-03-18?')
163163
print(result.output)
164164
#> There are 9,208 days between January 1, 2000, and March 18, 2025.
@@ -202,7 +202,7 @@ agent = Agent(
202202

203203

204204
async def main():
205-
async with agent:
205+
async with agent.setup():
206206
result = await agent.run('Echo with deps set to 42', deps=42)
207207
print(result.output)
208208
#> {"echo_deps":{"echo":"This is an echo message","deps":42}}
@@ -273,7 +273,7 @@ server = MCPServerSSE(
273273
agent = Agent("openai:gpt-4o", toolsets=[server])
274274

275275
async def main():
276-
async with agent:
276+
async with agent.setup():
277277
result = await agent.run('How many days between 2000-01-01 and 2025-03-18?')
278278
print(result.output)
279279
#> There are 9,208 days between January 1, 2000, and March 18, 2025.
@@ -285,7 +285,7 @@ async def main():
285285

286286
## MCP Sampling
287287

288-
!!! info "What is MCP Sampling?"
288+
!!! info What is MCP Sampling?
289289
In MCP [sampling](https://modelcontextprotocol.io/docs/concepts/sampling) is a system by which an MCP server can make LLM calls via the MCP client - effectively proxying requests to an LLM via the client over whatever transport is being used.
290290

291291
Sampling is extremely useful when MCP servers need to use Gen AI but you don't want to provision them each with their own LLM credentials or when a public MCP server would like the connecting client to pay for LLM calls.
@@ -318,11 +318,11 @@ Pydantic AI supports sampling as both a client and server. See the [server](./se
318318

319319
Sampling is automatically supported by Pydantic AI agents when they act as a client.
320320

321-
To be able to use sampling, an MCP server instance needs to have a [`sampling_model`][pydantic_ai.mcp.MCPServerStdio.sampling_model] set. This can be done either directly on the server using the constructor keyword argument or the property, or by using [`agent.set_mcp_sampling_model()`][pydantic_ai.Agent.set_mcp_sampling_model] to set the agent's model or one specified as an argument as the sampling model on all MCP servers registered with that agent.
321+
To be able to use sampling, an MCP server instance needs to have a [`sampling_model`][pydantic_ai.mcp.MCPServerStdio.sampling_model] set. This can be done either directly on the server using the constructor keyword argument or the property, or by using [`agent.set_mcp_sampling_model()`][pydantic_ai.Agent.set_mcp_sampling_model] to set the agents model or one specified as an argument as the sampling model on all MCP servers registered with that agent.
322322

323-
Let's say we have an MCP server that wants to use sampling (in this case to generate an SVG as per the tool arguments).
323+
Lets say we have an MCP server that wants to use sampling (in this case to generate an SVG as per the tool arguments).
324324

325-
??? example "Sampling MCP Server"
325+
??? example Sampling MCP Server
326326

327327
```python {title="generate_svg.py" py="3.10"}
328328
import re
@@ -371,14 +371,14 @@ agent = Agent('openai:gpt-4o', toolsets=[server])
371371

372372

373373
async def main():
374-
async with agent:
374+
async with agent.setup():
375375
agent.set_mcp_sampling_model()
376376
result = await agent.run('Create an image of a robot in a punk style.')
377377
print(result.output)
378378
#> Image file written to robot_punk.svg.
379379
```
380380

381-
_(This example is complete, it can be run "as is" with Python 3.10+)_
381+
_(This example is complete, it can be run as is with Python 3.10+)_
382382

383383
You can disallow sampling by setting [`allow_sampling=False`][pydantic_ai.mcp.MCPServerStdio.allow_sampling] when creating the server reference, e.g.:
384384

mcp-run-python/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ where:
3030
- `warmup` will run a minimal Python script to download and cache the Python standard library. This is also useful to
3131
check the server is running correctly.
3232

33-
Here's an example of using `@pydantic/mcp-run-python` with Pydantic AI:
33+
Heres an example of using `@pydantic/mcp-run-python` with Pydantic AI:
3434

3535
```python
3636
from pydantic_ai import Agent
@@ -56,7 +56,7 @@ agent = Agent('claude-3-5-haiku-latest', toolsets=[server])
5656

5757

5858
async def main():
59-
async with agent:
59+
async with agent.setup():
6060
result = await agent.run('How many days between 2000-01-01 and 2025-03-18?')
6161
print(result.output)
6262
#> There are 9,208 days between January 1, 2000, and March 18, 2025.w

pydantic_ai_slim/pydantic_ai/_a2a.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ async def worker_lifespan(app: FastA2A, worker: Worker, agent: Agent[AgentDepsT,
6464
6565
This ensures the worker is started and ready to process tasks as soon as the application starts.
6666
"""
67-
async with app.task_manager, agent:
67+
async with app.task_manager, agent.setup():
6868
async with worker.run():
6969
yield
7070

pydantic_ai_slim/pydantic_ai/agent.py

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import json
66
import warnings
77
from asyncio import Lock
8-
from collections.abc import AsyncIterator, Awaitable, Iterator, Mapping, Sequence
8+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Iterator, Mapping, Sequence
99
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager, contextmanager
1010
from contextvars import ContextVar
1111
from copy import deepcopy
@@ -548,19 +548,20 @@ async def main():
548548

549549
_utils.validate_empty_kwargs(_deprecated_kwargs)
550550

551-
async with self.iter(
552-
user_prompt=user_prompt,
553-
output_type=output_type,
554-
message_history=message_history,
555-
model=model,
556-
deps=deps,
557-
model_settings=model_settings,
558-
usage_limits=usage_limits,
559-
usage=usage,
560-
toolsets=toolsets,
561-
) as agent_run:
562-
async for _ in agent_run:
563-
pass
551+
async with self.setup():
552+
async with self.iter(
553+
user_prompt=user_prompt,
554+
output_type=output_type,
555+
message_history=message_history,
556+
model=model,
557+
deps=deps,
558+
model_settings=model_settings,
559+
usage_limits=usage_limits,
560+
usage=usage,
561+
toolsets=toolsets,
562+
) as agent_run:
563+
async for _ in agent_run:
564+
pass
564565

565566
assert agent_run.result is not None, 'The graph run did not finish properly'
566567
return agent_run.result
@@ -774,8 +775,8 @@ async def main():
774775

775776
toolset = self._get_toolset(output_toolset=output_toolset, additional_toolsets=toolsets)
776777
# This will raise errors for any name conflicts
777-
async with toolset:
778-
run_toolset = await ToolManager[AgentDepsT].build(toolset, run_context)
778+
async with self.setup():
779+
run_toolset = await ToolManager[AgentDepsT].build(toolset, ctx=run_context)
779780

780781
# Merge model settings in order of precedence: run > agent > model
781782
merged_settings = merge_model_settings(model_used.settings, self.model_settings)
@@ -1784,19 +1785,25 @@ def is_end_node(
17841785
"""
17851786
return isinstance(node, End)
17861787

1787-
async def __aenter__(self) -> Self:
1788+
@asynccontextmanager
1789+
async def setup(self) -> AsyncGenerator[Self, Any]:
17881790
"""Enter the agent context.
17891791
17901792
This will start all [`MCPServerStdio`s][pydantic_ai.mcp.MCPServerStdio] registered as `toolsets` so they are ready to be used.
1793+
"""
1794+
toolset = self._get_toolset()
1795+
async with toolset.setup():
1796+
yield self
1797+
1798+
async def __aenter__(self) -> Self:
1799+
"""Enter the agent context.
17911800
1792-
This is a no-op if the agent has already been entered.
1801+
A backwards compatible way to enter the Agent context
17931802
"""
17941803
async with self._enter_lock:
17951804
if self._entered_count == 0:
17961805
async with AsyncExitStack() as exit_stack:
1797-
toolset = self._get_toolset()
1798-
await exit_stack.enter_async_context(toolset)
1799-
1806+
await exit_stack.enter_async_context(self.setup())
18001807
self._exit_stack = exit_stack.pop_all()
18011808
self._entered_count += 1
18021809
return self
@@ -1828,7 +1835,7 @@ def _set_sampling_model(toolset: AbstractToolset[AgentDepsT]) -> None:
18281835

18291836
@asynccontextmanager
18301837
@deprecated(
1831-
'`run_mcp_servers` is deprecated, use `async with agent:` instead. If you need to set a sampling model on all MCP servers, use `agent.set_mcp_sampling_model()`.'
1838+
'`run_mcp_servers` is deprecated, use `async with agent.setup():` instead. If you need to set a sampling model on all MCP servers, use `agent.set_mcp_sampling_model()`.'
18321839
)
18331840
async def run_mcp_servers(
18341841
self, model: models.Model | models.KnownModelName | str | None = None
@@ -1846,7 +1853,7 @@ async def run_mcp_servers(
18461853
if model is not None:
18471854
raise
18481855

1849-
async with self:
1856+
async with self.setup():
18501857
yield
18511858

18521859
def to_ag_ui(

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import warnings
66
from abc import ABC, abstractmethod
77
from asyncio import Lock
8-
from collections.abc import AsyncIterator, Awaitable, Sequence
8+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Sequence
99
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
1010
from dataclasses import dataclass, field, replace
1111
from datetime import timedelta
@@ -72,7 +72,7 @@ class MCPServer(AbstractToolset[Any], ABC):
7272
_running_count: int
7373
_exit_stack: AsyncExitStack | None
7474

75-
_client: ClientSession
75+
_client: ClientSession | None = None
7676
_read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
7777
_write_stream: MemoryObjectSendStream[SessionMessage]
7878

@@ -99,6 +99,12 @@ async def client_streams(
9999
def name(self) -> str:
100100
return repr(self)
101101

102+
@property
103+
def client(self) -> ClientSession:
104+
if self._client is None:
105+
raise RuntimeError('MCP server is not running')
106+
return self._client
107+
102108
@property
103109
def tool_name_conflict_hint(self) -> str:
104110
return 'Consider setting `tool_prefix` to avoid name conflicts.'
@@ -110,8 +116,8 @@ async def list_tools(self) -> list[mcp_types.Tool]:
110116
- We don't cache tools as they might change.
111117
- We also don't subscribe to the server to avoid complexity.
112118
"""
113-
async with self: # Ensure server is running
114-
result = await self._client.list_tools()
119+
async with self.setup(): # Ensure server is running
120+
result = await self.client.list_tools()
115121
return result.tools
116122

117123
async def direct_call_tool(
@@ -133,9 +139,9 @@ async def direct_call_tool(
133139
Raises:
134140
ModelRetry: If the tool call fails.
135141
"""
136-
async with self: # Ensure server is running
142+
async with self.setup(): # Ensure server is running
137143
try:
138-
result = await self._client.send_request(
144+
result = await self.client.send_request(
139145
mcp_types.ClientRequest(
140146
mcp_types.CallToolRequest(
141147
method='tools/call',
@@ -191,6 +197,11 @@ async def get_tools(self, ctx: RunContext[Any]) -> dict[str, ToolsetTool[Any]]:
191197
if (name := f'{self.tool_prefix}_{mcp_tool.name}' if self.tool_prefix else mcp_tool.name)
192198
}
193199

200+
@asynccontextmanager
201+
async def setup(self) -> AsyncGenerator[Self, Any]:
202+
async with self:
203+
yield self
204+
194205
async def __aenter__(self) -> Self:
195206
"""Enter the MCP server context.
196207
@@ -286,7 +297,7 @@ async def _map_tool_result_part(
286297
resource = part.resource
287298
return self._get_content(resource)
288299
elif isinstance(part, mcp_types.ResourceLink):
289-
resource_result: mcp_types.ReadResourceResult = await self._client.read_resource(part.uri)
300+
resource_result: mcp_types.ReadResourceResult = await self.client.read_resource(part.uri)
290301
return (
291302
self._get_content(resource_result.contents[0])
292303
if len(resource_result.contents) == 1
@@ -339,7 +350,7 @@ class MCPServerStdio(MCPServer):
339350
agent = Agent('openai:gpt-4o', toolsets=[server])
340351
341352
async def main():
342-
async with agent: # (2)!
353+
async with agent.setup(): # (2)!
343354
...
344355
```
345356
@@ -629,7 +640,7 @@ class MCPServerSSE(_MCPServerHTTP):
629640
agent = Agent('openai:gpt-4o', toolsets=[server])
630641
631642
async def main():
632-
async with agent: # (2)!
643+
async with agent.setup(): # (2)!
633644
...
634645
```
635646
@@ -663,7 +674,7 @@ class MCPServerHTTP(MCPServerSSE):
663674
agent = Agent('openai:gpt-4o', toolsets=[server])
664675
665676
async def main():
666-
async with agent: # (2)!
677+
async with agent.setup(): # (2)!
667678
...
668679
```
669680
@@ -692,7 +703,7 @@ class MCPServerStreamableHTTP(_MCPServerHTTP):
692703
agent = Agent('openai:gpt-4o', toolsets=[server])
693704
694705
async def main():
695-
async with agent: # (2)!
706+
async with agent.setup(): # (2)!
696707
...
697708
```
698709
"""

0 commit comments

Comments
 (0)