From bf2e41d1d96d027ca2e71d0cb5a782e910f80a3a Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Wed, 26 Nov 2025 22:51:00 -0800 Subject: [PATCH 01/11] feat: allow custom clientInfo when connecting to mcp servers --- docs/mcp/client.md | 34 +++++++++++ pydantic_ai_slim/pydantic_ai/mcp.py | 26 +++++++++ tests/test_mcp.py | 88 +++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 815af8ffce..e4154b491e 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -473,6 +473,40 @@ async def main(): request. Anything supported by **httpx** (`verify`, `cert`, custom proxies, timeouts, etc.) therefore applies to all MCP traffic. +## Client Identification + +When connecting to an MCP server, you can optionally specify a client name and version that will be sent to the server during initialization. This is useful for: + +- Identifying your application in server logs +- Allowing servers to provide custom behavior based on the client +- Debugging and monitoring MCP connections +- Version-specific feature negotiation + +All MCP client classes ([`MCPServerStdio`][pydantic_ai.mcp.MCPServerStdio], [`MCPServerStreamableHTTP`][pydantic_ai.mcp.MCPServerStreamableHTTP], and [`MCPServerSSE`][pydantic_ai.mcp.MCPServerSSE]) support the `client_name` and `client_version` parameters: + +```python {title="mcp_client_with_name.py"} +from pydantic_ai import Agent +from pydantic_ai.mcp import MCPServerStdio + +server = MCPServerStdio( + 'uv', + args=['run', 'mcp-run-python', 'stdio'], + client_name='MyApplication', # (1)! + client_version='2.1.0', # (2)! +) +agent = Agent('openai:gpt-5', toolsets=[server]) + +async def main(): + result = await agent.run('What is 2 + 2?') + print(result.output) + #> The answer is 4. +``` + +1. The `client_name` parameter is sent to the MCP server as part of the `clientInfo` during initialization. +2. The `client_version` parameter specifies the version string. Defaults to `'1.0.0'` if not provided. + +When `client_name` is provided, it's sent to the server as an [Implementation](https://modelcontextprotocol.io/specification/2025-11-25/schema#implementation) object with the specified version (or `'1.0.0'` by default). If `client_name` is not specified, no client information is sent. + ## MCP Sampling !!! info "What is MCP Sampling?" diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index ac3cfeae5c..77ebef2048 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -346,6 +346,8 @@ def __init__( elicitation_callback: ElicitationFnT | None = None, *, id: str | None = None, + client_name: str | None = None, + client_version: str = '1.0.0', ): self.tool_prefix = tool_prefix self.log_level = log_level @@ -357,6 +359,8 @@ def __init__( self.sampling_model = sampling_model self.max_retries = max_retries self.elicitation_callback = elicitation_callback + self.client_name = client_name + self.client_version = client_version self._id = id or tool_prefix @@ -621,6 +625,15 @@ async def __aenter__(self) -> Self: if self._running_count == 0: async with AsyncExitStack() as exit_stack: self._read_stream, self._write_stream = await exit_stack.enter_async_context(self.client_streams()) + + # Build client_info if client_name is provided + client_info = None + if self.client_name: + client_info = mcp_types.Implementation( + name=self.client_name, + version=self.client_version, + ) + client = ClientSession( read_stream=self._read_stream, write_stream=self._write_stream, @@ -628,6 +641,7 @@ async def __aenter__(self) -> Self: elicitation_callback=self.elicitation_callback, logging_callback=self.log_handler, read_timeout_seconds=timedelta(seconds=self.read_timeout), + client_info=client_info, ) self._client = await exit_stack.enter_async_context(client) @@ -795,6 +809,8 @@ def __init__( max_retries: int = 1, elicitation_callback: ElicitationFnT | None = None, id: str | None = None, + client_name: str | None = None, + client_version: str = '1.0.0', ): """Build a new MCP server. @@ -814,6 +830,8 @@ def __init__( max_retries: The maximum number of times to retry a tool call. elicitation_callback: Callback function to handle elicitation requests from the server. id: An optional unique ID for the MCP server. An MCP server needs to have an ID in order to be used in a durable execution environment like Temporal, in which case the ID will be used to identify the server's activities within the workflow. + client_name: An optional client name to send to the MCP server in the clientInfo during initialization. + client_version: The version string to send with the client name. Defaults to '1.0.0'. """ self.command = command self.args = args @@ -832,6 +850,8 @@ def __init__( max_retries, elicitation_callback, id=id, + client_name=client_name, + client_version=client_version, ) @classmethod @@ -948,6 +968,8 @@ def __init__( sampling_model: models.Model | None = None, max_retries: int = 1, elicitation_callback: ElicitationFnT | None = None, + client_name: str | None = None, + client_version: str = '1.0.0', **_deprecated_kwargs: Any, ): """Build a new MCP server. @@ -967,6 +989,8 @@ def __init__( sampling_model: The model to use for sampling. max_retries: The maximum number of times to retry a tool call. elicitation_callback: Callback function to handle elicitation requests from the server. + client_name: An optional client name to send to the MCP server in the clientInfo during initialization. + client_version: The version string to send with the client name. Defaults to '1.0.0'. """ if 'sse_read_timeout' in _deprecated_kwargs: if read_timeout is not None: @@ -998,6 +1022,8 @@ def __init__( max_retries, elicitation_callback, id=id, + client_name=client_name, + client_version=client_version, ) @property diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 221ad37548..f4e4c22954 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -54,6 +54,7 @@ ElicitRequestParams, ElicitResult, ImageContent, + Implementation, TextContent, ) @@ -1981,6 +1982,93 @@ async def test_instructions(mcp_server: MCPServerStdio) -> None: assert mcp_server.instructions == 'Be a helpful assistant.' +async def test_client_name_passed_to_session() -> None: + """Test that client_name is passed to ClientSession as clientInfo.""" + server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], client_name='MyCustomClient') + + with patch.object(ClientSession, '__init__', return_value=None) as mock_init: + with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: + with patch.object(ClientSession, '__aexit__', new_callable=AsyncMock): + with patch.object(ClientSession, 'initialize', new_callable=AsyncMock) as mock_initialize: + # Mock the initialize response + mock_initialize.return_value = AsyncMock( + serverInfo=Implementation(name='test', version='1.0.0'), + capabilities=ServerCapabilities(tools=True), + instructions=None + ) + mock_aenter.return_value = AsyncMock(initialize=mock_initialize) + + async with server: + # Check that ClientSession.__init__ was called with client_info + assert mock_init.called + call_kwargs = mock_init.call_args.kwargs + + # Verify client_info was passed + assert 'client_info' in call_kwargs + client_info = call_kwargs['client_info'] + assert client_info is not None + assert client_info.name == 'MyCustomClient' + assert client_info.version == '1.0.0' + + +async def test_client_name_and_version_passed_to_session() -> None: + """Test that client_name and client_version are passed to ClientSession as clientInfo.""" + server = MCPServerStdio( + 'python', ['-m', 'tests.mcp_server'], + client_name='MyCustomClient', + client_version='2.5.3' + ) + + with patch.object(ClientSession, '__init__', return_value=None) as mock_init: + with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: + with patch.object(ClientSession, '__aexit__', new_callable=AsyncMock): + with patch.object(ClientSession, 'initialize', new_callable=AsyncMock) as mock_initialize: + # Mock the initialize response + mock_initialize.return_value = AsyncMock( + serverInfo=Implementation(name='test', version='1.0.0'), + capabilities=ServerCapabilities(tools=True), + instructions=None + ) + mock_aenter.return_value = AsyncMock(initialize=mock_initialize) + + async with server: + # Check that ClientSession.__init__ was called with client_info + assert mock_init.called + call_kwargs = mock_init.call_args.kwargs + + # Verify client_info was passed with custom version + assert 'client_info' in call_kwargs + client_info = call_kwargs['client_info'] + assert client_info is not None + assert client_info.name == 'MyCustomClient' + assert client_info.version == '2.5.3' + + +async def test_client_name_not_set() -> None: + """Test that when client_name is not set, client_info is None.""" + server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) + + with patch.object(ClientSession, '__init__', return_value=None) as mock_init: + with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: + with patch.object(ClientSession, '__aexit__', new_callable=AsyncMock): + with patch.object(ClientSession, 'initialize', new_callable=AsyncMock) as mock_initialize: + # Mock the initialize response + mock_initialize.return_value = AsyncMock( + serverInfo=Implementation(name='test', version='1.0.0'), + capabilities=ServerCapabilities(tools=True), + instructions=None + ) + mock_aenter.return_value = AsyncMock(initialize=mock_initialize) + + async with server: + # Check that ClientSession.__init__ was called with client_info=None + assert mock_init.called + call_kwargs = mock_init.call_args.kwargs + + assert 'client_info' in call_kwargs + assert call_kwargs['client_info'] is None + + async def test_agent_run_stream_with_mcp_server_http(allow_model_requests: None, model: Model): server = MCPServerStreamableHTTP(url='https://mcp.deepwiki.com/mcp', timeout=30) agent = Agent(model, toolsets=[server], instructions='Be concise.') From a25e32bb7f6aa4d56f0fcec32a7c90d5b16e8323 Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Wed, 26 Nov 2025 23:06:41 -0800 Subject: [PATCH 02/11] fix lint errors --- pydantic_ai_slim/pydantic_ai/mcp.py | 4 ++-- tests/test_mcp.py | 30 +++++++++++++---------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 77ebef2048..88458cddbe 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -625,7 +625,7 @@ async def __aenter__(self) -> Self: if self._running_count == 0: async with AsyncExitStack() as exit_stack: self._read_stream, self._write_stream = await exit_stack.enter_async_context(self.client_streams()) - + # Build client_info if client_name is provided client_info = None if self.client_name: @@ -633,7 +633,7 @@ async def __aenter__(self) -> Self: name=self.client_name, version=self.client_version, ) - + client = ClientSession( read_stream=self._read_stream, write_stream=self._write_stream, diff --git a/tests/test_mcp.py b/tests/test_mcp.py index f4e4c22954..01a25cdd1c 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1985,7 +1985,7 @@ async def test_instructions(mcp_server: MCPServerStdio) -> None: async def test_client_name_passed_to_session() -> None: """Test that client_name is passed to ClientSession as clientInfo.""" server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], client_name='MyCustomClient') - + with patch.object(ClientSession, '__init__', return_value=None) as mock_init: with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: with patch.object(ClientSession, '__aexit__', new_callable=AsyncMock): @@ -1994,15 +1994,15 @@ async def test_client_name_passed_to_session() -> None: mock_initialize.return_value = AsyncMock( serverInfo=Implementation(name='test', version='1.0.0'), capabilities=ServerCapabilities(tools=True), - instructions=None + instructions=None, ) mock_aenter.return_value = AsyncMock(initialize=mock_initialize) - + async with server: # Check that ClientSession.__init__ was called with client_info assert mock_init.called call_kwargs = mock_init.call_args.kwargs - + # Verify client_info was passed assert 'client_info' in call_kwargs client_info = call_kwargs['client_info'] @@ -2013,12 +2013,8 @@ async def test_client_name_passed_to_session() -> None: async def test_client_name_and_version_passed_to_session() -> None: """Test that client_name and client_version are passed to ClientSession as clientInfo.""" - server = MCPServerStdio( - 'python', ['-m', 'tests.mcp_server'], - client_name='MyCustomClient', - client_version='2.5.3' - ) - + server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], client_name='MyCustomClient', client_version='2.5.3') + with patch.object(ClientSession, '__init__', return_value=None) as mock_init: with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: with patch.object(ClientSession, '__aexit__', new_callable=AsyncMock): @@ -2027,15 +2023,15 @@ async def test_client_name_and_version_passed_to_session() -> None: mock_initialize.return_value = AsyncMock( serverInfo=Implementation(name='test', version='1.0.0'), capabilities=ServerCapabilities(tools=True), - instructions=None + instructions=None, ) mock_aenter.return_value = AsyncMock(initialize=mock_initialize) - + async with server: # Check that ClientSession.__init__ was called with client_info assert mock_init.called call_kwargs = mock_init.call_args.kwargs - + # Verify client_info was passed with custom version assert 'client_info' in call_kwargs client_info = call_kwargs['client_info'] @@ -2047,7 +2043,7 @@ async def test_client_name_and_version_passed_to_session() -> None: async def test_client_name_not_set() -> None: """Test that when client_name is not set, client_info is None.""" server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) - + with patch.object(ClientSession, '__init__', return_value=None) as mock_init: with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: with patch.object(ClientSession, '__aexit__', new_callable=AsyncMock): @@ -2056,15 +2052,15 @@ async def test_client_name_not_set() -> None: mock_initialize.return_value = AsyncMock( serverInfo=Implementation(name='test', version='1.0.0'), capabilities=ServerCapabilities(tools=True), - instructions=None + instructions=None, ) mock_aenter.return_value = AsyncMock(initialize=mock_initialize) - + async with server: # Check that ClientSession.__init__ was called with client_info=None assert mock_init.called call_kwargs = mock_init.call_args.kwargs - + assert 'client_info' in call_kwargs assert call_kwargs['client_info'] is None From c871c6196bf500913cfe5cc8b4d98b54f3429e50 Mon Sep 17 00:00:00 2001 From: atinylittleshell <3233006+atinylittleshell@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:49:22 -0800 Subject: [PATCH 03/11] Update MCP client info tests --- pydantic_ai_slim/pydantic_ai/mcp.py | 34 +++++------------- tests/test_mcp.py | 53 +++++++---------------------- 2 files changed, 21 insertions(+), 66 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/mcp.py b/pydantic_ai_slim/pydantic_ai/mcp.py index 88458cddbe..c50b900589 100644 --- a/pydantic_ai_slim/pydantic_ai/mcp.py +++ b/pydantic_ai_slim/pydantic_ai/mcp.py @@ -346,8 +346,7 @@ def __init__( elicitation_callback: ElicitationFnT | None = None, *, id: str | None = None, - client_name: str | None = None, - client_version: str = '1.0.0', + client_info: mcp_types.Implementation | None = None, ): self.tool_prefix = tool_prefix self.log_level = log_level @@ -359,8 +358,7 @@ def __init__( self.sampling_model = sampling_model self.max_retries = max_retries self.elicitation_callback = elicitation_callback - self.client_name = client_name - self.client_version = client_version + self.client_info = client_info self._id = id or tool_prefix @@ -626,14 +624,6 @@ async def __aenter__(self) -> Self: async with AsyncExitStack() as exit_stack: self._read_stream, self._write_stream = await exit_stack.enter_async_context(self.client_streams()) - # Build client_info if client_name is provided - client_info = None - if self.client_name: - client_info = mcp_types.Implementation( - name=self.client_name, - version=self.client_version, - ) - client = ClientSession( read_stream=self._read_stream, write_stream=self._write_stream, @@ -641,7 +631,7 @@ async def __aenter__(self) -> Self: elicitation_callback=self.elicitation_callback, logging_callback=self.log_handler, read_timeout_seconds=timedelta(seconds=self.read_timeout), - client_info=client_info, + client_info=self.client_info, ) self._client = await exit_stack.enter_async_context(client) @@ -809,8 +799,7 @@ def __init__( max_retries: int = 1, elicitation_callback: ElicitationFnT | None = None, id: str | None = None, - client_name: str | None = None, - client_version: str = '1.0.0', + client_info: mcp_types.Implementation | None = None, ): """Build a new MCP server. @@ -830,8 +819,7 @@ def __init__( max_retries: The maximum number of times to retry a tool call. elicitation_callback: Callback function to handle elicitation requests from the server. id: An optional unique ID for the MCP server. An MCP server needs to have an ID in order to be used in a durable execution environment like Temporal, in which case the ID will be used to identify the server's activities within the workflow. - client_name: An optional client name to send to the MCP server in the clientInfo during initialization. - client_version: The version string to send with the client name. Defaults to '1.0.0'. + client_info: Information describing the MCP client implementation. """ self.command = command self.args = args @@ -850,8 +838,7 @@ def __init__( max_retries, elicitation_callback, id=id, - client_name=client_name, - client_version=client_version, + client_info=client_info, ) @classmethod @@ -968,8 +955,7 @@ def __init__( sampling_model: models.Model | None = None, max_retries: int = 1, elicitation_callback: ElicitationFnT | None = None, - client_name: str | None = None, - client_version: str = '1.0.0', + client_info: mcp_types.Implementation | None = None, **_deprecated_kwargs: Any, ): """Build a new MCP server. @@ -989,8 +975,7 @@ def __init__( sampling_model: The model to use for sampling. max_retries: The maximum number of times to retry a tool call. elicitation_callback: Callback function to handle elicitation requests from the server. - client_name: An optional client name to send to the MCP server in the clientInfo during initialization. - client_version: The version string to send with the client name. Defaults to '1.0.0'. + client_info: Information describing the MCP client implementation. """ if 'sse_read_timeout' in _deprecated_kwargs: if read_timeout is not None: @@ -1022,8 +1007,7 @@ def __init__( max_retries, elicitation_callback, id=id, - client_name=client_name, - client_version=client_version, + client_info=client_info, ) @property diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 01a25cdd1c..3fe9a1f089 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1982,38 +1982,15 @@ async def test_instructions(mcp_server: MCPServerStdio) -> None: assert mcp_server.instructions == 'Be a helpful assistant.' -async def test_client_name_passed_to_session() -> None: - """Test that client_name is passed to ClientSession as clientInfo.""" - server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], client_name='MyCustomClient') - - with patch.object(ClientSession, '__init__', return_value=None) as mock_init: - with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: - with patch.object(ClientSession, '__aexit__', new_callable=AsyncMock): - with patch.object(ClientSession, 'initialize', new_callable=AsyncMock) as mock_initialize: - # Mock the initialize response - mock_initialize.return_value = AsyncMock( - serverInfo=Implementation(name='test', version='1.0.0'), - capabilities=ServerCapabilities(tools=True), - instructions=None, - ) - mock_aenter.return_value = AsyncMock(initialize=mock_initialize) - - async with server: - # Check that ClientSession.__init__ was called with client_info - assert mock_init.called - call_kwargs = mock_init.call_args.kwargs - - # Verify client_info was passed - assert 'client_info' in call_kwargs - client_info = call_kwargs['client_info'] - assert client_info is not None - assert client_info.name == 'MyCustomClient' - assert client_info.version == '1.0.0' - - -async def test_client_name_and_version_passed_to_session() -> None: - """Test that client_name and client_version are passed to ClientSession as clientInfo.""" - server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], client_name='MyCustomClient', client_version='2.5.3') +async def test_client_info_passed_to_session() -> None: + """Test that provided client_info is passed unchanged to ClientSession.""" + implementation = Implementation( + name='MyCustomClient', + version='2.5.3', + description='Custom MCP client', + url='https://example.com/client', + ) + server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], client_info=implementation) with patch.object(ClientSession, '__init__', return_value=None) as mock_init: with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: @@ -2028,20 +2005,15 @@ async def test_client_name_and_version_passed_to_session() -> None: mock_aenter.return_value = AsyncMock(initialize=mock_initialize) async with server: - # Check that ClientSession.__init__ was called with client_info assert mock_init.called call_kwargs = mock_init.call_args.kwargs - # Verify client_info was passed with custom version assert 'client_info' in call_kwargs - client_info = call_kwargs['client_info'] - assert client_info is not None - assert client_info.name == 'MyCustomClient' - assert client_info.version == '2.5.3' + assert call_kwargs['client_info'] is implementation -async def test_client_name_not_set() -> None: - """Test that when client_name is not set, client_info is None.""" +async def test_client_info_not_set() -> None: + """Test that when client_info is not set, None is passed through.""" server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) with patch.object(ClientSession, '__init__', return_value=None) as mock_init: @@ -2057,7 +2029,6 @@ async def test_client_name_not_set() -> None: mock_aenter.return_value = AsyncMock(initialize=mock_initialize) async with server: - # Check that ClientSession.__init__ was called with client_info=None assert mock_init.called call_kwargs = mock_init.call_args.kwargs From 64a4614435d7588ad541850860a5c6d36b4f49d9 Mon Sep 17 00:00:00 2001 From: atinylittleshell <3233006+atinylittleshell@users.noreply.github.com> Date: Fri, 28 Nov 2025 21:34:07 -0800 Subject: [PATCH 04/11] Fix MCP Implementation fields in client info test --- tests/test_mcp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 3fe9a1f089..69fbbf1127 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1987,8 +1987,8 @@ async def test_client_info_passed_to_session() -> None: implementation = Implementation( name='MyCustomClient', version='2.5.3', - description='Custom MCP client', - url='https://example.com/client', + title='Custom MCP client', + websiteUrl='https://example.com/client', ) server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], client_info=implementation) From d0a77752e3cca6e256ac9c59706f572b7fe7e61c Mon Sep 17 00:00:00 2001 From: atinylittleshell <3233006+atinylittleshell@users.noreply.github.com> Date: Fri, 28 Nov 2025 22:22:23 -0800 Subject: [PATCH 05/11] Update MCP client identification example --- docs/mcp/client.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index e4154b491e..058a445563 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -475,24 +475,27 @@ async def main(): ## Client Identification -When connecting to an MCP server, you can optionally specify a client name and version that will be sent to the server during initialization. This is useful for: +When connecting to an MCP server, you can optionally specify client information that will be sent to the server during initialization. This is useful for: - Identifying your application in server logs - Allowing servers to provide custom behavior based on the client - Debugging and monitoring MCP connections - Version-specific feature negotiation -All MCP client classes ([`MCPServerStdio`][pydantic_ai.mcp.MCPServerStdio], [`MCPServerStreamableHTTP`][pydantic_ai.mcp.MCPServerStreamableHTTP], and [`MCPServerSSE`][pydantic_ai.mcp.MCPServerSSE]) support the `client_name` and `client_version` parameters: +All MCP client classes ([`MCPServerStdio`][pydantic_ai.mcp.MCPServerStdio], [`MCPServerStreamableHTTP`][pydantic_ai.mcp.MCPServerStreamableHTTP], and [`MCPServerSSE`][pydantic_ai.mcp.MCPServerSSE]) support the `client_info` parameter: ```python {title="mcp_client_with_name.py"} +from mcp import types as mcp_types from pydantic_ai import Agent from pydantic_ai.mcp import MCPServerStdio server = MCPServerStdio( 'uv', args=['run', 'mcp-run-python', 'stdio'], - client_name='MyApplication', # (1)! - client_version='2.1.0', # (2)! + client_info=mcp_types.Implementation( # (1)! + name='MyApplication', + version='2.1.0', + ), ) agent = Agent('openai:gpt-5', toolsets=[server]) @@ -502,10 +505,9 @@ async def main(): #> The answer is 4. ``` -1. The `client_name` parameter is sent to the MCP server as part of the `clientInfo` during initialization. -2. The `client_version` parameter specifies the version string. Defaults to `'1.0.0'` if not provided. +1. The `client_info` parameter is sent to the MCP server as part of the `clientInfo` during initialization. -When `client_name` is provided, it's sent to the server as an [Implementation](https://modelcontextprotocol.io/specification/2025-11-25/schema#implementation) object with the specified version (or `'1.0.0'` by default). If `client_name` is not specified, no client information is sent. +When `client_info` is provided, it's sent to the server as an [Implementation](https://modelcontextprotocol.io/specification/2025-11-25/schema#implementation) object. If `client_info` is not specified, no client information is sent. ## MCP Sampling From 97a78e52f76c127fea134ffa6eed7f547c1781ff Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Fri, 28 Nov 2025 22:50:49 -0800 Subject: [PATCH 06/11] fix ruff format in example --- docs/mcp/client.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 058a445563..25d599457f 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -486,6 +486,7 @@ All MCP client classes ([`MCPServerStdio`][pydantic_ai.mcp.MCPServerStdio], [`MC ```python {title="mcp_client_with_name.py"} from mcp import types as mcp_types + from pydantic_ai import Agent from pydantic_ai.mcp import MCPServerStdio From d30f528da61450071de21d66d7043aac064201bf Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Fri, 28 Nov 2025 23:58:10 -0800 Subject: [PATCH 07/11] fix failed doc test --- tests/test_examples.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 3490f0dd3e..abb6e18aed 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -149,6 +149,7 @@ def print(self, *args: Any, **kwargs: Any) -> None: mocker.patch('pydantic_ai.mcp.MCPServerSSE', return_value=MockMCPServer()) mocker.patch('pydantic_ai.mcp.MCPServerStreamableHTTP', return_value=MockMCPServer()) + mocker.patch('pydantic_ai.mcp.MCPServerStdio', return_value=MockMCPServer()) mocker.patch('mcp.server.fastmcp.FastMCP') env.set('OPENAI_API_KEY', 'testing') @@ -327,6 +328,7 @@ async def call_tool( 'Use the web to get the current time.': "In San Francisco, it's 8:21:41 pm PDT on Wednesday, August 6, 2025.", 'Give me a sentence with the biggest news in AI this week.': 'Scientists have developed a universal AI detector that can identify deepfake videos.', 'How many days between 2000-01-01 and 2025-03-18?': 'There are 9,208 days between January 1, 2000, and March 18, 2025.', + 'What is 2 + 2?': 'The answer is 4.', 'What is 7 plus 5?': 'The answer is 12.', 'What is the weather like in West London and in Wiltshire?': ( 'The weather in West London is raining, while in Wiltshire it is sunny.' From b61eb75184a486c003df14c7cdd5681f6a6fb53d Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Sat, 29 Nov 2025 00:16:35 -0800 Subject: [PATCH 08/11] fix failed doc test --- docs/mcp/client.md | 12 ++++++------ tests/test_examples.py | 2 -- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index 25d599457f..b6d55b4d52 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -488,11 +488,10 @@ All MCP client classes ([`MCPServerStdio`][pydantic_ai.mcp.MCPServerStdio], [`MC from mcp import types as mcp_types from pydantic_ai import Agent -from pydantic_ai.mcp import MCPServerStdio +from pydantic_ai.mcp import MCPServerSSE -server = MCPServerStdio( - 'uv', - args=['run', 'mcp-run-python', 'stdio'], +server = MCPServerSSE( + 'http://localhost:3001/sse', client_info=mcp_types.Implementation( # (1)! name='MyApplication', version='2.1.0', @@ -500,10 +499,11 @@ server = MCPServerStdio( ) agent = Agent('openai:gpt-5', toolsets=[server]) + async def main(): - result = await agent.run('What is 2 + 2?') + result = await agent.run('How many days between 2000-01-01 and 2025-03-18?') print(result.output) - #> The answer is 4. + #> There are 9,208 days between January 1, 2000, and March 18, 2025. ``` 1. The `client_info` parameter is sent to the MCP server as part of the `clientInfo` during initialization. diff --git a/tests/test_examples.py b/tests/test_examples.py index abb6e18aed..3490f0dd3e 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -149,7 +149,6 @@ def print(self, *args: Any, **kwargs: Any) -> None: mocker.patch('pydantic_ai.mcp.MCPServerSSE', return_value=MockMCPServer()) mocker.patch('pydantic_ai.mcp.MCPServerStreamableHTTP', return_value=MockMCPServer()) - mocker.patch('pydantic_ai.mcp.MCPServerStdio', return_value=MockMCPServer()) mocker.patch('mcp.server.fastmcp.FastMCP') env.set('OPENAI_API_KEY', 'testing') @@ -328,7 +327,6 @@ async def call_tool( 'Use the web to get the current time.': "In San Francisco, it's 8:21:41 pm PDT on Wednesday, August 6, 2025.", 'Give me a sentence with the biggest news in AI this week.': 'Scientists have developed a universal AI detector that can identify deepfake videos.', 'How many days between 2000-01-01 and 2025-03-18?': 'There are 9,208 days between January 1, 2000, and March 18, 2025.', - 'What is 2 + 2?': 'The answer is 4.', 'What is 7 plus 5?': 'The answer is 12.', 'What is the weather like in West London and in Wiltshire?': ( 'The weather in West London is raining, while in Wiltshire it is sunny.' From 9117b4db9f431eae6e39834b72e92818eef55186 Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Tue, 2 Dec 2025 23:46:36 -0800 Subject: [PATCH 09/11] addressing review comments --- docs/mcp/client.md | 3 +-- tests/mcp_server.py | 19 ++++++++++++++++ tests/test_mcp.py | 55 +++++++++++++-------------------------------- 3 files changed, 36 insertions(+), 41 deletions(-) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index b6d55b4d52..f08db14f8f 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -503,10 +503,9 @@ agent = Agent('openai:gpt-5', toolsets=[server]) async def main(): result = await agent.run('How many days between 2000-01-01 and 2025-03-18?') print(result.output) - #> There are 9,208 days between January 1, 2000, and March 18, 2025. ``` -1. The `client_info` parameter is sent to the MCP server as part of the `clientInfo` during initialization. +The `client_info` parameter is sent to the MCP server as part of the `clientInfo` during initialization. When `client_info` is provided, it's sent to the server as an [Implementation](https://modelcontextprotocol.io/specification/2025-11-25/schema#implementation) object. If `client_info` is not specified, no client information is sent. diff --git a/tests/mcp_server.py b/tests/mcp_server.py index 8ba9b9997f..6edc0b9183 100644 --- a/tests/mcp_server.py +++ b/tests/mcp_server.py @@ -223,6 +223,25 @@ class UserResponse(BaseModel): response: str +@mcp.tool() +async def get_client_info(ctx: Context[ServerSession, None]) -> dict[str, Any] | None: + """Get information about the connected MCP client. + + Returns: + Dictionary with client info (name, version, etc.) or None if not available. + """ + client_params = ctx.session.client_params + if client_params is None: + return None + client_info = client_params.clientInfo + return { + 'name': client_info.name, + 'version': client_info.version, + 'title': getattr(client_info, 'title', None), + 'websiteUrl': getattr(client_info, 'websiteUrl', None), + } + + @mcp.tool() async def use_elicitation(ctx: Context[ServerSession, None], question: str) -> str: """Use elicitation callback to ask the user a question.""" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 69fbbf1127..7c64920105 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -96,7 +96,7 @@ async def test_stdio_server(run_context: RunContext[int]): server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) async with server: tools = [tool.tool_def for tool in (await server.get_tools(run_context)).values()] - assert len(tools) == snapshot(18) + assert len(tools) == snapshot(19) assert tools[0].name == 'celsius_to_fahrenheit' assert isinstance(tools[0].description, str) assert tools[0].description.startswith('Convert Celsius to Fahrenheit.') @@ -157,7 +157,7 @@ async def test_stdio_server_with_cwd(run_context: RunContext[int]): server = MCPServerStdio('python', ['mcp_server.py'], cwd=test_dir) async with server: tools = await server.get_tools(run_context) - assert len(tools) == snapshot(18) + assert len(tools) == snapshot(19) async def test_process_tool_call(run_context: RunContext[int]) -> int: @@ -1992,48 +1992,25 @@ async def test_client_info_passed_to_session() -> None: ) server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], client_info=implementation) - with patch.object(ClientSession, '__init__', return_value=None) as mock_init: - with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: - with patch.object(ClientSession, '__aexit__', new_callable=AsyncMock): - with patch.object(ClientSession, 'initialize', new_callable=AsyncMock) as mock_initialize: - # Mock the initialize response - mock_initialize.return_value = AsyncMock( - serverInfo=Implementation(name='test', version='1.0.0'), - capabilities=ServerCapabilities(tools=True), - instructions=None, - ) - mock_aenter.return_value = AsyncMock(initialize=mock_initialize) - - async with server: - assert mock_init.called - call_kwargs = mock_init.call_args.kwargs - - assert 'client_info' in call_kwargs - assert call_kwargs['client_info'] is implementation + async with server: + result = await server.direct_call_tool('get_client_info', {}) + assert result == { + 'name': 'MyCustomClient', + 'version': '2.5.3', + 'title': 'Custom MCP client', + 'websiteUrl': 'https://example.com/client', + } async def test_client_info_not_set() -> None: - """Test that when client_info is not set, None is passed through.""" + """Test that when client_info is not set, the default MCP client info is used.""" server = MCPServerStdio('python', ['-m', 'tests.mcp_server']) - with patch.object(ClientSession, '__init__', return_value=None) as mock_init: - with patch.object(ClientSession, '__aenter__', new_callable=AsyncMock) as mock_aenter: - with patch.object(ClientSession, '__aexit__', new_callable=AsyncMock): - with patch.object(ClientSession, 'initialize', new_callable=AsyncMock) as mock_initialize: - # Mock the initialize response - mock_initialize.return_value = AsyncMock( - serverInfo=Implementation(name='test', version='1.0.0'), - capabilities=ServerCapabilities(tools=True), - instructions=None, - ) - mock_aenter.return_value = AsyncMock(initialize=mock_initialize) - - async with server: - assert mock_init.called - call_kwargs = mock_init.call_args.kwargs - - assert 'client_info' in call_kwargs - assert call_kwargs['client_info'] is None + async with server: + result = await server.direct_call_tool('get_client_info', {}) + # When client_info is not set, the MCP library provides default client info + assert result is not None + assert result['name'] == 'mcp' async def test_agent_run_stream_with_mcp_server_http(allow_model_requests: None, model: Model): From 3bb4ba0401c47c9c341077ca6cac3dd079740276 Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Wed, 3 Dec 2025 09:26:06 -0800 Subject: [PATCH 10/11] fix lint errors --- tests/test_mcp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7c64920105..2c119eb658 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -2010,6 +2010,7 @@ async def test_client_info_not_set() -> None: result = await server.direct_call_tool('get_client_info', {}) # When client_info is not set, the MCP library provides default client info assert result is not None + assert isinstance(result, dict) assert result['name'] == 'mcp' From c08ed2525e68601ddee89c841296386e3f792691 Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Wed, 3 Dec 2025 22:50:19 -0800 Subject: [PATCH 11/11] fix test error --- docs/mcp/client.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mcp/client.md b/docs/mcp/client.md index f08db14f8f..c9a0f539f4 100644 --- a/docs/mcp/client.md +++ b/docs/mcp/client.md @@ -503,6 +503,7 @@ agent = Agent('openai:gpt-5', toolsets=[server]) async def main(): result = await agent.run('How many days between 2000-01-01 and 2025-03-18?') print(result.output) + #> There are 9,208 days between January 1, 2000, and March 18, 2025. ``` The `client_info` parameter is sent to the MCP server as part of the `clientInfo` during initialization.