Skip to content

Commit f8e6e0f

Browse files
authored
Load MCP servers from file (#2698)
1 parent b00646d commit f8e6e0f

File tree

4 files changed

+226
-9
lines changed

4 files changed

+226
-9
lines changed

docs/mcp/client.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,59 @@ async def main():
163163

164164
1. See [MCP Run Python](https://github.com/pydantic/mcp-run-python) for more information.
165165

166+
## Loading MCP Servers from Configuration
167+
168+
Instead of creating MCP server instances individually in code, you can load multiple servers from a JSON configuration file using [`load_mcp_servers()`][pydantic_ai.mcp.load_mcp_servers].
169+
170+
This is particularly useful when you need to manage multiple MCP servers or want to configure servers externally without modifying code.
171+
172+
### Configuration Format
173+
174+
The configuration file should be a JSON file with an `mcpServers` object containing server definitions. Each server is identified by a unique key and contains the configuration for that server type:
175+
176+
```json {title="mcp_config.json"}
177+
{
178+
"mcpServers": {
179+
"python-runner": {
180+
"command": "uv",
181+
"args": ["run", "mcp-run-python", "stdio"]
182+
},
183+
"weather-api": {
184+
"url": "http://localhost:3001/sse"
185+
},
186+
"calculator": {
187+
"url": "http://localhost:8000/mcp"
188+
}
189+
}
190+
}
191+
```
192+
193+
!!! note
194+
The MCP server is only inferred to be an SSE server because of the `/sse` suffix.
195+
Any other server with the "url" field will be inferred to be a Streamable HTTP server.
196+
197+
We made this decision given that the SSE transport is deprecated.
198+
199+
### Usage
200+
201+
```python {title="mcp_config_loader.py" test="skip"}
202+
from pydantic_ai import Agent
203+
from pydantic_ai.mcp import load_mcp_servers
204+
205+
# Load all servers from configuration file
206+
servers = load_mcp_servers('mcp_config.json')
207+
208+
# Create agent with all loaded servers
209+
agent = Agent('openai:gpt-5', toolsets=servers)
210+
211+
async def main():
212+
async with agent:
213+
result = await agent.run('What is 7 plus 5?')
214+
print(result.output)
215+
```
216+
217+
_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
218+
166219
## Tool call customisation
167220

168221
The MCP servers provide the ability to set a `process_tool_call` which allows

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
from dataclasses import field, replace
1111
from datetime import timedelta
1212
from pathlib import Path
13-
from typing import Any
13+
from typing import Annotated, Any
1414

1515
import anyio
1616
import httpx
1717
import pydantic_core
1818
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
19+
from pydantic import BaseModel, Discriminator, Field, Tag
20+
from pydantic_core import CoreSchema, core_schema
1921
from typing_extensions import Self, assert_never, deprecated
2022

2123
from pydantic_ai.tools import RunContext, ToolDefinition
@@ -41,7 +43,7 @@
4143
# after mcp imports so any import error maps to this file, not _mcp.py
4244
from . import _mcp, _utils, exceptions, messages, models
4345

44-
__all__ = 'MCPServer', 'MCPServerStdio', 'MCPServerHTTP', 'MCPServerSSE', 'MCPServerStreamableHTTP'
46+
__all__ = 'MCPServer', 'MCPServerStdio', 'MCPServerHTTP', 'MCPServerSSE', 'MCPServerStreamableHTTP', 'load_mcp_servers'
4547

4648
TOOL_SCHEMA_VALIDATOR = pydantic_core.SchemaValidator(
4749
schema=pydantic_core.core_schema.dict_schema(
@@ -498,6 +500,22 @@ def __init__(
498500
id=id,
499501
)
500502

503+
@classmethod
504+
def __get_pydantic_core_schema__(cls, _: Any, __: Any) -> CoreSchema:
505+
return core_schema.no_info_after_validator_function(
506+
lambda dct: MCPServerStdio(**dct),
507+
core_schema.typed_dict_schema(
508+
{
509+
'command': core_schema.typed_dict_field(core_schema.str_schema()),
510+
'args': core_schema.typed_dict_field(core_schema.list_schema(core_schema.str_schema())),
511+
'env': core_schema.typed_dict_field(
512+
core_schema.dict_schema(core_schema.str_schema(), core_schema.str_schema()),
513+
required=False,
514+
),
515+
}
516+
),
517+
)
518+
501519
@asynccontextmanager
502520
async def client_streams(
503521
self,
@@ -520,6 +538,16 @@ def __repr__(self) -> str:
520538
repr_args.append(f'id={self.id!r}')
521539
return f'{self.__class__.__name__}({", ".join(repr_args)})'
522540

541+
def __eq__(self, value: object, /) -> bool:
542+
if not isinstance(value, MCPServerStdio):
543+
return False # pragma: no cover
544+
return (
545+
self.command == value.command
546+
and self.args == value.args
547+
and self.env == value.env
548+
and self.cwd == value.cwd
549+
)
550+
523551

524552
class _MCPServerHTTP(MCPServer):
525553
url: str
@@ -733,10 +761,29 @@ async def main():
733761
1. This will connect to a server running on `localhost:3001`.
734762
"""
735763

764+
@classmethod
765+
def __get_pydantic_core_schema__(cls, _: Any, __: Any) -> CoreSchema:
766+
return core_schema.no_info_after_validator_function(
767+
lambda dct: MCPServerSSE(**dct),
768+
core_schema.typed_dict_schema(
769+
{
770+
'url': core_schema.typed_dict_field(core_schema.str_schema()),
771+
'headers': core_schema.typed_dict_field(
772+
core_schema.dict_schema(core_schema.str_schema(), core_schema.str_schema()), required=False
773+
),
774+
}
775+
),
776+
)
777+
736778
@property
737779
def _transport_client(self):
738780
return sse_client # pragma: no cover
739781

782+
def __eq__(self, value: object, /) -> bool:
783+
if not isinstance(value, MCPServerSSE):
784+
return False # pragma: no cover
785+
return self.url == value.url
786+
740787

741788
@deprecated('The `MCPServerHTTP` class is deprecated, use `MCPServerSSE` instead.')
742789
class MCPServerHTTP(MCPServerSSE):
@@ -790,10 +837,29 @@ async def main():
790837
```
791838
"""
792839

840+
@classmethod
841+
def __get_pydantic_core_schema__(cls, _: Any, __: Any) -> CoreSchema:
842+
return core_schema.no_info_after_validator_function(
843+
lambda dct: MCPServerStreamableHTTP(**dct),
844+
core_schema.typed_dict_schema(
845+
{
846+
'url': core_schema.typed_dict_field(core_schema.str_schema()),
847+
'headers': core_schema.typed_dict_field(
848+
core_schema.dict_schema(core_schema.str_schema(), core_schema.str_schema()), required=False
849+
),
850+
}
851+
),
852+
)
853+
793854
@property
794855
def _transport_client(self):
795856
return streamablehttp_client # pragma: no cover
796857

858+
def __eq__(self, value: object, /) -> bool:
859+
if not isinstance(value, MCPServerStreamableHTTP):
860+
return False # pragma: no cover
861+
return self.url == value.url
862+
797863

798864
ToolResult = (
799865
str
@@ -823,3 +889,50 @@ def _transport_client(self):
823889
Allows wrapping an MCP server tool call to customize it, including adding extra request
824890
metadata.
825891
"""
892+
893+
894+
def _mcp_server_discriminator(value: dict[str, Any]) -> str | None:
895+
if 'url' in value:
896+
if value['url'].endswith('/sse'):
897+
return 'sse'
898+
return 'streamable-http'
899+
return 'stdio'
900+
901+
902+
class MCPServerConfig(BaseModel):
903+
"""Configuration for MCP servers."""
904+
905+
mcp_servers: Annotated[
906+
dict[
907+
str,
908+
Annotated[
909+
Annotated[MCPServerStdio, Tag('stdio')]
910+
| Annotated[MCPServerStreamableHTTP, Tag('streamable-http')]
911+
| Annotated[MCPServerSSE, Tag('sse')],
912+
Discriminator(_mcp_server_discriminator),
913+
],
914+
],
915+
Field(alias='mcpServers'),
916+
]
917+
918+
919+
def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServerStreamableHTTP | MCPServerSSE]:
920+
"""Load MCP servers from a configuration file.
921+
922+
Args:
923+
config_path: The path to the configuration file.
924+
925+
Returns:
926+
A list of MCP servers.
927+
928+
Raises:
929+
FileNotFoundError: If the configuration file does not exist.
930+
ValidationError: If the configuration file does not match the schema.
931+
"""
932+
config_path = Path(config_path)
933+
934+
if not config_path.exists():
935+
raise FileNotFoundError(f'Config file {config_path} not found')
936+
937+
config = MCPServerConfig.model_validate_json(config_path.read_bytes())
938+
return list(config.mcp_servers.values())

tests/models/cassettes/test_model_names/test_known_model_names.yaml

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,19 +99,53 @@ interactions:
9999
alt-svc:
100100
- h3=":443"; ma=86400
101101
content-length:
102-
- '99'
102+
- '762'
103103
content-type:
104104
- application/json
105105
referrer-policy:
106106
- strict-origin-when-cross-origin
107107
strict-transport-security:
108108
- max-age=3600; includeSubDomains
109109
parsed_body:
110-
code: wrong_api_key
111-
message: Wrong API Key
112-
param: api_key
113-
type: invalid_request_error
110+
data:
111+
- created: 0
112+
id: llama3.1-8b
113+
object: model
114+
owned_by: Cerebras
115+
- created: 0
116+
id: qwen-3-coder-480b
117+
object: model
118+
owned_by: Cerebras
119+
- created: 0
120+
id: llama-4-maverick-17b-128e-instruct
121+
object: model
122+
owned_by: Cerebras
123+
- created: 0
124+
id: qwen-3-235b-a22b-thinking-2507
125+
object: model
126+
owned_by: Cerebras
127+
- created: 0
128+
id: llama-3.3-70b
129+
object: model
130+
owned_by: Cerebras
131+
- created: 0
132+
id: qwen-3-32b
133+
object: model
134+
owned_by: Cerebras
135+
- created: 0
136+
id: qwen-3-235b-a22b-instruct-2507
137+
object: model
138+
owned_by: Cerebras
139+
- created: 0
140+
id: llama-4-scout-17b-16e-instruct
141+
object: model
142+
owned_by: Cerebras
143+
- created: 0
144+
id: gpt-oss-120b
145+
object: model
146+
owned_by: Cerebras
147+
object: list
114148
status:
115-
code: 401
116-
message: Unauthorized
149+
code: 200
150+
message: OK
117151
version: 1

tests/test_mcp.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from pydantic_ai.agent import Agent
1616
from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior, UserError
17+
from pydantic_ai.mcp import MCPServerStreamableHTTP, load_mcp_servers
1718
from pydantic_ai.messages import (
1819
BinaryContent,
1920
ModelRequest,
@@ -1375,3 +1376,19 @@ async def test_elicitation_callback_not_set(run_context: RunContext[int]):
13751376
# Should raise an error when elicitation is attempted without callback
13761377
with pytest.raises(ModelRetry, match='Elicitation not supported'):
13771378
await server.direct_call_tool('use_elicitation', {'question': 'Should I continue?'})
1379+
1380+
1381+
def test_load_mcp_servers(tmp_path: Path):
1382+
config = tmp_path / 'mcp.json'
1383+
1384+
config.write_text('{"mcpServers": {"potato": {"url": "https://example.com/mcp"}}}')
1385+
assert load_mcp_servers(config) == snapshot([MCPServerStreamableHTTP(url='https://example.com/mcp')])
1386+
1387+
config.write_text('{"mcpServers": {"potato": {"command": "python", "args": ["-m", "tests.mcp_server"]}}}')
1388+
assert load_mcp_servers(config) == snapshot([MCPServerStdio(command='python', args=['-m', 'tests.mcp_server'])])
1389+
1390+
config.write_text('{"mcpServers": {"potato": {"url": "https://example.com/sse"}}}')
1391+
assert load_mcp_servers(config) == snapshot([MCPServerSSE(url='https://example.com/sse')])
1392+
1393+
with pytest.raises(FileNotFoundError):
1394+
load_mcp_servers(tmp_path / 'does_not_exist.json')

0 commit comments

Comments
 (0)