Skip to content

Commit f955890

Browse files
authored
support MCP logging, increase minimum mcp version to 1.6.0 (#1436)
1 parent 7487ab4 commit f955890

File tree

5 files changed

+76
-7
lines changed

5 files changed

+76
-7
lines changed

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Any
1010

1111
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
12-
from mcp.types import JSONRPCMessage
12+
from mcp.types import JSONRPCMessage, LoggingLevel
1313
from typing_extensions import Self
1414

1515
from pydantic_ai.tools import ToolDefinition
@@ -52,6 +52,11 @@ async def client_streams(
5252
raise NotImplementedError('MCP Server subclasses must implement this method.')
5353
yield
5454

55+
@abstractmethod
56+
def _get_log_level(self) -> LoggingLevel | None:
57+
"""Get the log level for the MCP server."""
58+
raise NotImplementedError('MCP Server subclasses must implement this method.')
59+
5560
async def list_tools(self) -> list[ToolDefinition]:
5661
"""Retrieve tools that are currently active on the server.
5762
@@ -89,6 +94,8 @@ async def __aenter__(self) -> Self:
8994
self._client = await self._exit_stack.enter_async_context(client)
9095

9196
await self._client.initialize()
97+
if log_level := self._get_log_level():
98+
await self._client.set_logging_level(log_level)
9299
self.is_running = True
93100
return self
94101

@@ -150,6 +157,13 @@ async def main():
150157
By default the subprocess will not inherit any environment variables from the parent process.
151158
If you want to inherit the environment variables from the parent process, use `env=os.environ`.
152159
"""
160+
log_level: LoggingLevel | None = None
161+
"""The log level to set when connecting to the server, if any.
162+
163+
See <https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/logging#logging> for more details.
164+
165+
If `None`, no log level will be set.
166+
"""
153167

154168
cwd: str | Path | None = None
155169
"""The working directory to use when spawning the process."""
@@ -164,6 +178,9 @@ async def client_streams(
164178
async with stdio_client(server=server) as (read_stream, write_stream):
165179
yield read_stream, write_stream
166180

181+
def _get_log_level(self) -> LoggingLevel | None:
182+
return self.log_level
183+
167184

168185
@dataclass
169186
class MCPServerHTTP(MCPServer):
@@ -223,6 +240,13 @@ async def main():
223240
If no new messages are received within this time, the connection will be considered stale
224241
and may be closed. Defaults to 5 minutes (300 seconds).
225242
"""
243+
log_level: LoggingLevel | None = None
244+
"""The log level to set when connecting to the server, if any.
245+
246+
See <https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/logging#logging> for more details.
247+
248+
If `None`, no log level will be set.
249+
"""
226250

227251
@asynccontextmanager
228252
async def client_streams(
@@ -234,3 +258,6 @@ async def client_streams(
234258
url=self.url, headers=self.headers, timeout=self.timeout, sse_read_timeout=self.sse_read_timeout
235259
) as (read_stream, write_stream):
236260
yield read_stream, write_stream
261+
262+
def _get_log_level(self) -> LoggingLevel | None:
263+
return self.log_level

pydantic_ai_slim/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ tavily = ["tavily-python>=0.5.0"]
6969
# CLI
7070
cli = ["rich>=13", "prompt-toolkit>=3", "argcomplete>=3.5.0"]
7171
# MCP
72-
mcp = ["mcp>=1.5.0; python_version >= '3.10'"]
72+
mcp = ["mcp>=1.6.0; python_version >= '3.10'"]
7373
# Evals
7474
evals = ["pydantic-evals=={{ version }}"]
7575

tests/mcp_server.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from mcp.server.fastmcp import FastMCP
1+
from mcp.server.fastmcp import Context, FastMCP
22

33
mcp = FastMCP('PydanticAI MCP Server')
4+
log_level = 'unset'
45

56

67
@mcp.tool()
@@ -16,4 +17,22 @@ async def celsius_to_fahrenheit(celsius: float) -> float:
1617
return (celsius * 9 / 5) + 32
1718

1819

19-
mcp.run()
20+
@mcp.tool()
21+
async def get_log_level(ctx: Context) -> str: # type: ignore
22+
"""Get the current log level.
23+
24+
Returns:
25+
The current log level.
26+
"""
27+
await ctx.info('this is a log message')
28+
return log_level
29+
30+
31+
@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage]
32+
async def set_logging_level(level: str) -> None:
33+
global log_level
34+
log_level = level
35+
36+
37+
if __name__ == '__main__':
38+
mcp.run()

tests/test_mcp.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async def test_stdio_server():
3131
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
3232
async with server:
3333
tools = await server.list_tools()
34-
assert len(tools) == 1
34+
assert len(tools) == 2
3535
assert tools[0].name == 'celsius_to_fahrenheit'
3636
assert tools[0].description.startswith('Convert Celsius to Fahrenheit.')
3737

@@ -45,12 +45,13 @@ async def test_stdio_server_with_cwd():
4545
server = MCPServerStdio('python', ['mcp_server.py'], cwd=test_dir)
4646
async with server:
4747
tools = await server.list_tools()
48-
assert len(tools) == 1
48+
assert len(tools) == 2
4949

5050

5151
def test_sse_server():
5252
sse_server = MCPServerHTTP(url='http://localhost:8000/sse')
5353
assert sse_server.url == 'http://localhost:8000/sse'
54+
assert sse_server._get_log_level() is None # pyright: ignore[reportPrivateUsage]
5455

5556

5657
def test_sse_server_with_header_and_timeout():
@@ -59,11 +60,13 @@ def test_sse_server_with_header_and_timeout():
5960
headers={'my-custom-header': 'my-header-value'},
6061
timeout=10,
6162
sse_read_timeout=100,
63+
log_level='info',
6264
)
6365
assert sse_server.url == 'http://localhost:8000/sse'
6466
assert sse_server.headers is not None and sse_server.headers['my-custom-header'] == 'my-header-value'
6567
assert sse_server.timeout == 10
6668
assert sse_server.sse_read_timeout == 100
69+
assert sse_server._get_log_level() == 'info' # pyright: ignore[reportPrivateUsage]
6770

6871

6972
async def test_agent_with_stdio_server(allow_model_requests: None, openai_api_key: str):
@@ -114,3 +117,23 @@ async def test_agent_with_server_not_running(openai_api_key: str):
114117
agent = Agent(model, mcp_servers=[server])
115118
with pytest.raises(UserError, match='MCP server is not running'):
116119
await agent.run('What is 0 degrees Celsius in Fahrenheit?')
120+
121+
122+
async def test_log_level_unset():
123+
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
124+
assert server._get_log_level() is None # pyright: ignore[reportPrivateUsage]
125+
async with server:
126+
tools = await server.list_tools()
127+
assert len(tools) == 2
128+
assert tools[1].name == 'get_log_level'
129+
130+
result = await server.call_tool('get_log_level', {})
131+
assert result.content == snapshot([TextContent(type='text', text='unset')])
132+
133+
134+
async def test_log_level_set():
135+
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], log_level='info')
136+
assert server._get_log_level() == 'info' # pyright: ignore[reportPrivateUsage]
137+
async with server:
138+
result = await server.call_tool('get_log_level', {})
139+
assert result.content == snapshot([TextContent(type='text', text='info')])

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)