Skip to content

pydantic_ai.mcp

MCPError

Bases: RuntimeError

Raised when an MCP server returns an error response.

This exception wraps error responses from MCP servers, following the ErrorData schema from the MCP specification.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
class MCPError(RuntimeError):
    """Raised when an MCP server returns an error response.

    This exception wraps error responses from MCP servers, following the ErrorData schema
    from the MCP specification.
    """

    message: str
    """The error message."""

    code: int
    """The error code returned by the server."""

    data: dict[str, Any] | None
    """Additional information about the error, if provided by the server."""

    def __init__(self, message: str, code: int, data: dict[str, Any] | None = None):
        self.message = message
        self.code = code
        self.data = data
        super().__init__(message)

    @classmethod
    def from_mcp_sdk(cls, error: mcp_exceptions.McpError) -> MCPError:
        """Create an MCPError from an MCP SDK McpError.

        Args:
            error: An McpError from the MCP SDK.
        """
        # Extract error data from the McpError.error attribute
        error_data = error.error
        return cls(message=error_data.message, code=error_data.code, data=error_data.data)

    def __str__(self) -> str:
        if self.data:
            return f'{self.message} (code: {self.code}, data: {self.data})'
        return f'{self.message} (code: {self.code})'

message instance-attribute

message: str = message

The error message.

code instance-attribute

code: int = code

The error code returned by the server.

data instance-attribute

data: dict[str, Any] | None = data

Additional information about the error, if provided by the server.

from_mcp_sdk classmethod

from_mcp_sdk(error: McpError) -> MCPError

Create an MCPError from an MCP SDK McpError.

Parameters:

Name Type Description Default
error McpError

An McpError from the MCP SDK.

required
Source code in pydantic_ai_slim/pydantic_ai/mcp.py
118
119
120
121
122
123
124
125
126
127
@classmethod
def from_mcp_sdk(cls, error: mcp_exceptions.McpError) -> MCPError:
    """Create an MCPError from an MCP SDK McpError.

    Args:
        error: An McpError from the MCP SDK.
    """
    # Extract error data from the McpError.error attribute
    error_data = error.error
    return cls(message=error_data.message, code=error_data.code, data=error_data.data)

ResourceAnnotations dataclass

Additional properties describing MCP entities.

See the resource annotations in the MCP specification.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
@dataclass(repr=False, kw_only=True)
class ResourceAnnotations:
    """Additional properties describing MCP entities.

    See the [resource annotations in the MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#annotations).
    """

    audience: list[mcp_types.Role] | None = None
    """Intended audience for this entity."""

    priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None
    """Priority level for this entity, ranging from 0.0 to 1.0."""

    __repr__ = _utils.dataclasses_no_defaults_repr

    @classmethod
    def from_mcp_sdk(cls, mcp_annotations: mcp_types.Annotations) -> ResourceAnnotations:
        """Convert from MCP SDK Annotations to ResourceAnnotations.

        Args:
            mcp_annotations: The MCP SDK annotations object.
        """
        return cls(audience=mcp_annotations.audience, priority=mcp_annotations.priority)

audience class-attribute instance-attribute

audience: list[Role] | None = None

Intended audience for this entity.

priority class-attribute instance-attribute

priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = (
    None
)

Priority level for this entity, ranging from 0.0 to 1.0.

from_mcp_sdk classmethod

from_mcp_sdk(
    mcp_annotations: Annotations,
) -> ResourceAnnotations

Convert from MCP SDK Annotations to ResourceAnnotations.

Parameters:

Name Type Description Default
mcp_annotations Annotations

The MCP SDK annotations object.

required
Source code in pydantic_ai_slim/pydantic_ai/mcp.py
150
151
152
153
154
155
156
157
@classmethod
def from_mcp_sdk(cls, mcp_annotations: mcp_types.Annotations) -> ResourceAnnotations:
    """Convert from MCP SDK Annotations to ResourceAnnotations.

    Args:
        mcp_annotations: The MCP SDK annotations object.
    """
    return cls(audience=mcp_annotations.audience, priority=mcp_annotations.priority)

BaseResource dataclass

Bases: ABC

Base class for MCP resources.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
@dataclass(repr=False, kw_only=True)
class BaseResource(ABC):
    """Base class for MCP resources."""

    name: str
    """The programmatic name of the resource."""

    title: str | None = None
    """Human-readable title for UI contexts."""

    description: str | None = None
    """A description of what this resource represents."""

    mime_type: str | None = None
    """The MIME type of the resource, if known."""

    annotations: ResourceAnnotations | None = None
    """Optional annotations for the resource."""

    metadata: dict[str, Any] | None = None
    """Optional metadata for the resource."""

    __repr__ = _utils.dataclasses_no_defaults_repr

name instance-attribute

name: str

The programmatic name of the resource.

title class-attribute instance-attribute

title: str | None = None

Human-readable title for UI contexts.

description class-attribute instance-attribute

description: str | None = None

A description of what this resource represents.

mime_type class-attribute instance-attribute

mime_type: str | None = None

The MIME type of the resource, if known.

annotations class-attribute instance-attribute

annotations: ResourceAnnotations | None = None

Optional annotations for the resource.

metadata class-attribute instance-attribute

metadata: dict[str, Any] | None = None

Optional metadata for the resource.

Resource dataclass

Bases: BaseResource

A resource that can be read from an MCP server.

See the resources in the MCP specification.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
@dataclass(repr=False, kw_only=True)
class Resource(BaseResource):
    """A resource that can be read from an MCP server.

    See the [resources in the MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/server/resources).
    """

    uri: str
    """The URI of the resource."""

    size: int | None = None
    """The size of the raw resource content in bytes (before base64 encoding), if known."""

    @classmethod
    def from_mcp_sdk(cls, mcp_resource: mcp_types.Resource) -> Resource:
        """Convert from MCP SDK Resource to PydanticAI Resource.

        Args:
            mcp_resource: The MCP SDK Resource object.
        """
        return cls(
            uri=str(mcp_resource.uri),
            name=mcp_resource.name,
            title=mcp_resource.title,
            description=mcp_resource.description,
            mime_type=mcp_resource.mimeType,
            size=mcp_resource.size,
            annotations=ResourceAnnotations.from_mcp_sdk(mcp_resource.annotations)
            if mcp_resource.annotations
            else None,
            metadata=mcp_resource.meta,
        )

uri instance-attribute

uri: str

The URI of the resource.

size class-attribute instance-attribute

size: int | None = None

The size of the raw resource content in bytes (before base64 encoding), if known.

from_mcp_sdk classmethod

from_mcp_sdk(mcp_resource: Resource) -> Resource

Convert from MCP SDK Resource to PydanticAI Resource.

Parameters:

Name Type Description Default
mcp_resource Resource

The MCP SDK Resource object.

required
Source code in pydantic_ai_slim/pydantic_ai/mcp.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
@classmethod
def from_mcp_sdk(cls, mcp_resource: mcp_types.Resource) -> Resource:
    """Convert from MCP SDK Resource to PydanticAI Resource.

    Args:
        mcp_resource: The MCP SDK Resource object.
    """
    return cls(
        uri=str(mcp_resource.uri),
        name=mcp_resource.name,
        title=mcp_resource.title,
        description=mcp_resource.description,
        mime_type=mcp_resource.mimeType,
        size=mcp_resource.size,
        annotations=ResourceAnnotations.from_mcp_sdk(mcp_resource.annotations)
        if mcp_resource.annotations
        else None,
        metadata=mcp_resource.meta,
    )

ResourceTemplate dataclass

Bases: BaseResource

A template for parameterized resources on an MCP server.

See the resource templates in the MCP specification.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
@dataclass(repr=False, kw_only=True)
class ResourceTemplate(BaseResource):
    """A template for parameterized resources on an MCP server.

    See the [resource templates in the MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#resource-templates).
    """

    uri_template: str
    """URI template (RFC 6570) for constructing resource URIs."""

    @classmethod
    def from_mcp_sdk(cls, mcp_template: mcp_types.ResourceTemplate) -> ResourceTemplate:
        """Convert from MCP SDK ResourceTemplate to PydanticAI ResourceTemplate.

        Args:
            mcp_template: The MCP SDK ResourceTemplate object.
        """
        return cls(
            uri_template=mcp_template.uriTemplate,
            name=mcp_template.name,
            title=mcp_template.title,
            description=mcp_template.description,
            mime_type=mcp_template.mimeType,
            annotations=ResourceAnnotations.from_mcp_sdk(mcp_template.annotations)
            if mcp_template.annotations
            else None,
            metadata=mcp_template.meta,
        )

uri_template instance-attribute

uri_template: str

URI template (RFC 6570) for constructing resource URIs.

from_mcp_sdk classmethod

from_mcp_sdk(
    mcp_template: ResourceTemplate,
) -> ResourceTemplate

Convert from MCP SDK ResourceTemplate to PydanticAI ResourceTemplate.

Parameters:

Name Type Description Default
mcp_template ResourceTemplate

The MCP SDK ResourceTemplate object.

required
Source code in pydantic_ai_slim/pydantic_ai/mcp.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
@classmethod
def from_mcp_sdk(cls, mcp_template: mcp_types.ResourceTemplate) -> ResourceTemplate:
    """Convert from MCP SDK ResourceTemplate to PydanticAI ResourceTemplate.

    Args:
        mcp_template: The MCP SDK ResourceTemplate object.
    """
    return cls(
        uri_template=mcp_template.uriTemplate,
        name=mcp_template.name,
        title=mcp_template.title,
        description=mcp_template.description,
        mime_type=mcp_template.mimeType,
        annotations=ResourceAnnotations.from_mcp_sdk(mcp_template.annotations)
        if mcp_template.annotations
        else None,
        metadata=mcp_template.meta,
    )

ServerCapabilities dataclass

Capabilities that an MCP server supports.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
@dataclass(repr=False, kw_only=True)
class ServerCapabilities:
    """Capabilities that an MCP server supports."""

    experimental: list[str] | None = None
    """Experimental, non-standard capabilities that the server supports."""

    logging: bool = False
    """Whether the server supports sending log messages to the client."""

    prompts: bool = False
    """Whether the server offers any prompt templates."""

    prompts_list_changed: bool = False
    """Whether the server will emit notifications when the list of prompts changes."""

    resources: bool = False
    """Whether the server offers any resources to read."""

    resources_list_changed: bool = False
    """Whether the server will emit notifications when the list of resources changes."""

    tools: bool = False
    """Whether the server offers any tools to call."""

    tools_list_changed: bool = False
    """Whether the server will emit notifications when the list of tools changes."""

    completions: bool = False
    """Whether the server offers autocompletion suggestions for prompts and resources."""

    __repr__ = _utils.dataclasses_no_defaults_repr

    @classmethod
    def from_mcp_sdk(cls, mcp_capabilities: mcp_types.ServerCapabilities) -> ServerCapabilities:
        """Convert from MCP SDK ServerCapabilities to PydanticAI ServerCapabilities.

        Args:
            mcp_capabilities: The MCP SDK ServerCapabilities object.
        """
        prompts_cap = mcp_capabilities.prompts
        resources_cap = mcp_capabilities.resources
        tools_cap = mcp_capabilities.tools
        return cls(
            experimental=list(mcp_capabilities.experimental.keys()) if mcp_capabilities.experimental else None,
            logging=mcp_capabilities.logging is not None,
            prompts=prompts_cap is not None,
            prompts_list_changed=bool(prompts_cap.listChanged) if prompts_cap else False,
            resources=resources_cap is not None,
            resources_list_changed=bool(resources_cap.listChanged) if resources_cap else False,
            tools=tools_cap is not None,
            tools_list_changed=bool(tools_cap.listChanged) if tools_cap else False,
            completions=mcp_capabilities.completions is not None,
        )

experimental class-attribute instance-attribute

experimental: list[str] | None = None

Experimental, non-standard capabilities that the server supports.

logging class-attribute instance-attribute

logging: bool = False

Whether the server supports sending log messages to the client.

prompts class-attribute instance-attribute

prompts: bool = False

Whether the server offers any prompt templates.

prompts_list_changed class-attribute instance-attribute

prompts_list_changed: bool = False

Whether the server will emit notifications when the list of prompts changes.

resources class-attribute instance-attribute

resources: bool = False

Whether the server offers any resources to read.

resources_list_changed class-attribute instance-attribute

resources_list_changed: bool = False

Whether the server will emit notifications when the list of resources changes.

tools class-attribute instance-attribute

tools: bool = False

Whether the server offers any tools to call.

tools_list_changed class-attribute instance-attribute

tools_list_changed: bool = False

Whether the server will emit notifications when the list of tools changes.

completions class-attribute instance-attribute

completions: bool = False

Whether the server offers autocompletion suggestions for prompts and resources.

from_mcp_sdk classmethod

from_mcp_sdk(
    mcp_capabilities: ServerCapabilities,
) -> ServerCapabilities

Convert from MCP SDK ServerCapabilities to PydanticAI ServerCapabilities.

Parameters:

Name Type Description Default
mcp_capabilities ServerCapabilities

The MCP SDK ServerCapabilities object.

required
Source code in pydantic_ai_slim/pydantic_ai/mcp.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
@classmethod
def from_mcp_sdk(cls, mcp_capabilities: mcp_types.ServerCapabilities) -> ServerCapabilities:
    """Convert from MCP SDK ServerCapabilities to PydanticAI ServerCapabilities.

    Args:
        mcp_capabilities: The MCP SDK ServerCapabilities object.
    """
    prompts_cap = mcp_capabilities.prompts
    resources_cap = mcp_capabilities.resources
    tools_cap = mcp_capabilities.tools
    return cls(
        experimental=list(mcp_capabilities.experimental.keys()) if mcp_capabilities.experimental else None,
        logging=mcp_capabilities.logging is not None,
        prompts=prompts_cap is not None,
        prompts_list_changed=bool(prompts_cap.listChanged) if prompts_cap else False,
        resources=resources_cap is not None,
        resources_list_changed=bool(resources_cap.listChanged) if resources_cap else False,
        tools=tools_cap is not None,
        tools_list_changed=bool(tools_cap.listChanged) if tools_cap else False,
        completions=mcp_capabilities.completions is not None,
    )

ToolResult module-attribute

ToolResult = (
    str
    | BinaryContent
    | dict[str, Any]
    | list[Any]
    | Sequence[
        str | BinaryContent | dict[str, Any] | list[Any]
    ]
)

The result type of an MCP tool call.

CallToolFunc

Bases: Protocol

A callable that invokes an MCP tool — typically MCPToolset.direct_call_tool or its legacy equivalent.

Passed to user-defined ProcessToolCallback functions as the underlying call hook. metadata is keyword-only — pass it as await call_tool(name, args, metadata=...).

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
class CallToolFunc(Protocol):
    """A callable that invokes an MCP tool — typically `MCPToolset.direct_call_tool` or its legacy equivalent.

    Passed to user-defined [`ProcessToolCallback`][pydantic_ai.mcp.ProcessToolCallback] functions as
    the underlying call hook. `metadata` is keyword-only — pass it as
    `await call_tool(name, args, metadata=...)`.
    """

    async def __call__(
        self,
        name: str,
        args: dict[str, Any],
        *,
        metadata: dict[str, Any] | None = None,
    ) -> ToolResult: ...

ProcessToolCallback module-attribute

ProcessToolCallback = Callable[
    [RunContext[Any], CallToolFunc, str, dict[str, Any]],
    Awaitable[ToolResult],
]

A process tool callback.

It accepts a run context, the original tool call function, a tool name, and arguments.

Allows wrapping an MCP server tool call to customize it, including adding extra request metadata.

MCPToolsetClient module-attribute

MCPToolsetClient: TypeAlias = (
    Client[Any]
    | ClientTransport
    | FastMCP
    | FastMCP
    | AnyUrl
    | Path
    | str
)

Anything MCPToolset accepts as its client argument — a pre-built fastmcp.Client, a FastMCP ClientTransport, an in-process FastMCP server, an AnyUrl/URL string, a script Path, or a URL/path/script string.

For multi-server JSON config files, use load_mcp_toolsets instead — it expands env vars and constructs one MCPToolset per server entry.

MCPToolset dataclass

Bases: AbstractToolset[AgentDepsT]

A toolset for connecting to an MCP server.

MCPToolset is the recommended way to use Model Context Protocol servers in Pydantic AI. It is built on the FastMCP Client, which supports the full MCP protocol — tools, resources, sampling, elicitation, OAuth — and a wide range of transports (HTTP, SSE, stdio, in-process FastMCP servers, multi-server configs).

Pass any input that FastMCP can build a transport from — a URL, a script path, a FastMCP server instance for in-process testing — or a pre-built fastmcp.Client for full control over its configuration. For multi-server JSON config files, use load_mcp_toolsets instead.

Example — connect to a streamable-HTTP MCP server:

from pydantic_ai import Agent
from pydantic_ai.mcp import MCPToolset

toolset = MCPToolset('http://localhost:8000/mcp')
agent = Agent('openai:gpt-5', toolsets=[toolset])

Example — connect to a local stdio MCP server:

from pydantic_ai.mcp import MCPToolset

toolset = MCPToolset('my_mcp_server.py')

Example — pass a pre-built FastMCP Client for full configuration control:

from fastmcp.client import Client
from fastmcp.client.transports import StreamableHttpTransport

from pydantic_ai.mcp import MCPToolset

client = Client(StreamableHttpTransport('http://localhost:8000/mcp'), auth='oauth')
toolset = MCPToolset(client)
Source code in pydantic_ai_slim/pydantic_ai/mcp.py
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
@dataclass(init=False, repr=False)
class MCPToolset(AbstractToolset[AgentDepsT]):
    """A toolset for connecting to an MCP server.

    `MCPToolset` is the recommended way to use [Model Context Protocol](https://modelcontextprotocol.io)
    servers in Pydantic AI. It is built on the [FastMCP](https://gofastmcp.com) `Client`, which
    supports the full MCP protocol — tools, resources, sampling, elicitation, OAuth — and a wide
    range of transports (HTTP, SSE, stdio, in-process FastMCP servers, multi-server configs).

    Pass any input that FastMCP can build a transport from — a URL, a script path, a `FastMCP`
    server instance for in-process testing — or a pre-built `fastmcp.Client` for full control over
    its configuration. For multi-server JSON config files, use
    [`load_mcp_toolsets`][pydantic_ai.mcp.load_mcp_toolsets] instead.

    Example — connect to a streamable-HTTP MCP server:

    ```python {test="skip"}
    from pydantic_ai import Agent
    from pydantic_ai.mcp import MCPToolset

    toolset = MCPToolset('http://localhost:8000/mcp')
    agent = Agent('openai:gpt-5', toolsets=[toolset])
    ```

    Example — connect to a local stdio MCP server:

    ```python {test="skip"}
    from pydantic_ai.mcp import MCPToolset

    toolset = MCPToolset('my_mcp_server.py')
    ```

    Example — pass a pre-built FastMCP Client for full configuration control:

    ```python {test="skip"}
    from fastmcp.client import Client
    from fastmcp.client.transports import StreamableHttpTransport

    from pydantic_ai.mcp import MCPToolset

    client = Client(StreamableHttpTransport('http://localhost:8000/mcp'), auth='oauth')
    toolset = MCPToolset(client)
    ```
    """

    client: FastMCPClient[Any]
    """The underlying FastMCP `Client`. Always normalized to a `fastmcp.Client` regardless of how
    the toolset was constructed."""

    tool_error_behavior: Literal['retry', 'error']
    """How to handle tool errors raised by the server.

    `'retry'` (default) raises [`ModelRetry`][pydantic_ai.exceptions.ModelRetry] so the model can
    self-correct; `'error'` propagates the underlying `fastmcp.exceptions.ToolError` to the caller.
    """

    max_retries: int | None
    """Maximum number of times a tool call may be retried after a `ModelRetry`.

    `None` (default) inherits the agent's retry count at runtime. Set explicitly to override.
    """

    cache_tools: bool
    """Whether to cache the list of tools across `get_tools()` calls.

    When enabled (default), tools are fetched once and cached until either:

    - The server sends a `notifications/tools/list_changed` notification
    - The toolset is fully exited (last `__aexit__` matches the first `__aenter__`)

    Set to `False` for servers that change tools dynamically without sending notifications, or when
    passing a pre-built FastMCP Client (the cache-invalidation message handler isn't installed in
    that case, so caches are only invalidated by session close).
    """

    cache_resources: bool
    """Whether to cache the list of resources across `list_resources()` calls.

    Same semantics as [`cache_tools`][pydantic_ai.mcp.MCPToolset.cache_tools] but for
    `notifications/resources/list_changed` notifications.
    """

    include_instructions: bool
    """Whether to include the server's `initialize` instructions string in the agent's instruction set.

    Defaults to `False` for backward compatibility. When `True`, the instructions returned by the
    server during initialization are added to the agent's instructions.
    """

    include_return_schema: bool | None
    """Whether to include each tool's `outputSchema` in the schema sent to the model.

    When `None` (the default), defaults to `False` unless the
    [`IncludeToolReturnSchemas`][pydantic_ai.capabilities.IncludeToolReturnSchemas] capability is
    used.
    """

    process_tool_call: ProcessToolCallback | None
    """Hook to wrap tool calls — useful for adding request-level metadata, custom retry policies,
    or telemetry. See [`ProcessToolCallback`][pydantic_ai.mcp.ProcessToolCallback].
    """

    sampling_model: models.Model | None
    """A Pydantic AI model that the server may sample from via the MCP `sampling/createMessage` flow.

    When set (and no explicit `sampling_handler` is passed), Pydantic AI builds a sampling handler
    that delegates to this model with the request's `maxTokens`/`temperature`/`stopSequences`
    settings applied. If both `sampling_model` and `sampling_handler` are passed, an error is raised.
    """

    log_level: mcp_types.LoggingLevel | None
    """Log level requested from the server via `logging/setLevel` after initialization.

    `None` (default) leaves the server's default log level alone. Combine with `log_handler` to
    receive log messages.
    """

    _id: str | None
    _server_info: mcp_types.Implementation | None
    _server_capabilities: ServerCapabilities | None
    _instructions: str | None
    _cached_tools: list[mcp_types.Tool] | None
    _cached_resources: list[Resource] | None
    _running_count: int
    _exit_stack: AsyncExitStack | None
    _user_message_handler: MessageHandlerT | None

    @functools.cached_property
    def _enter_lock(self) -> anyio.Lock:
        # `anyio.Lock` binds to the event loop on which it's first used; deferring creation to first
        # access ensures it binds to the running loop and avoids issues with Temporal's workflow sandbox.
        return anyio.Lock()

    def __init__(
        self,
        client: MCPToolsetClient,
        *,
        # Pydantic AI-layer config
        id: str | None = None,
        max_retries: int | None = None,
        tool_error_behavior: Literal['retry', 'error'] = 'retry',
        process_tool_call: ProcessToolCallback | None = None,
        cache_tools: bool = True,
        cache_resources: bool = True,
        include_instructions: bool = False,
        include_return_schema: bool | None = None,
        # Sampling — high-level shortcut and low-level escape hatch
        sampling_model: models.Model | None = None,
        sampling_handler: SamplingHandler[Any, Any] | None = None,
        # MCP protocol kwargs (forwarded to a default FastMCP Client when one isn't passed)
        elicitation_handler: ElicitationHandler[Any, Any] | None = None,
        log_handler: LogHandler | None = None,
        log_level: mcp_types.LoggingLevel | None = None,
        progress_handler: ProgressHandler | None = None,
        message_handler: MessageHandlerT | None = None,
        client_info: mcp_types.Implementation | None = None,
        init_timeout: float | None = _UNSET,
        read_timeout: float | None = _UNSET,
        roots: RootsList | RootsHandler[Any] | None = None,
        # HTTP-specific (only used when constructing a default transport from a URL)
        auth: httpx.Auth | Literal['oauth'] | str | None = None,
        verify: ssl.SSLContext | bool | str | None = None,
        headers: dict[str, str] | None = None,
        http_client: httpx.AsyncClient | None = None,
    ):
        """Build a new `MCPToolset`.

        Args:
            client: How to connect to the MCP server. See the class docstring for accepted shapes.
            id: An optional unique identifier for this toolset. Required for use in durable execution
                environments like Temporal or DBOS, where it identifies the toolset's activities/steps
                within a workflow.
            max_retries: Maximum number of times a tool call may be retried after a `ModelRetry`.
                `None` inherits the agent's retry count at runtime.
            tool_error_behavior: `'retry'` (default) raises
                [`ModelRetry`][pydantic_ai.exceptions.ModelRetry] on tool errors so the model can
                self-correct; `'error'` propagates the underlying exception.
            process_tool_call: Hook to wrap tool calls. See
                [`ProcessToolCallback`][pydantic_ai.mcp.ProcessToolCallback].
            cache_tools: Whether to cache the list of tools. See
                [`MCPToolset.cache_tools`][pydantic_ai.mcp.MCPToolset.cache_tools].
            cache_resources: Whether to cache the list of resources. See
                [`MCPToolset.cache_resources`][pydantic_ai.mcp.MCPToolset.cache_resources].
            include_instructions: Whether to include the server's instructions in the agent's
                instructions. See
                [`MCPToolset.include_instructions`][pydantic_ai.mcp.MCPToolset.include_instructions].
            include_return_schema: Whether to include return schemas in tool definitions. See
                [`MCPToolset.include_return_schema`][pydantic_ai.mcp.MCPToolset.include_return_schema].
            sampling_model: A Pydantic AI model the server may sample from. Mutually exclusive with
                `sampling_handler`.
            sampling_handler: A FastMCP-shaped sampling handler. Use for full control over the
                sampling response.
            elicitation_handler: A FastMCP-shaped elicitation handler that receives MCP
                `elicitation/create` requests from the server.
            log_handler: A FastMCP-shaped log handler that receives log messages from the server.
            log_level: Log level requested from the server via `logging/setLevel` after
                initialization.
            progress_handler: A FastMCP-shaped progress handler.
            message_handler: A FastMCP-shaped message handler called for every server-sent message.
                Pydantic AI installs its own message handler internally to invalidate caches on
                `list_changed` notifications; if you provide one, both run (yours after ours).
            client_info: Information describing the MCP client implementation, sent to the server
                during initialization.
            init_timeout: Timeout in seconds for the initial connection and `initialize` handshake.
            read_timeout: Maximum time in seconds to wait for new messages on the long-lived
                connection. Defaults to 5 minutes.
            roots: Filesystem roots advertised to the server.
            auth: HTTP authentication for HTTP transports — an `httpx.Auth`, the literal string
                `'oauth'` to enable FastMCP's OAuth flow, or a bearer-token string.
            verify: SSL verification mode for HTTP transports — an `ssl.SSLContext`, a CA bundle
                path string, or a bool.
            headers: Extra HTTP headers for HTTP transports. Mutually exclusive with `http_client`.
            http_client: A pre-configured `httpx.AsyncClient` to use for HTTP transports — useful
                for self-signed certificates or custom connection pooling. Mutually exclusive with
                `headers`.

        Raises:
            ValueError: If a pre-built `fastmcp.Client` is passed alongside any of the kwargs that
                would otherwise build a default Client (sampling, elicitation, headers, etc.), or
                if `sampling_model` and `sampling_handler` are both passed, or if `headers` and
                `http_client` are both passed.
        """
        if isinstance(client, FastMCPClient):
            forwarded_values: dict[str, Any] = {
                'sampling_handler': sampling_handler,
                'sampling_model': sampling_model,
                'elicitation_handler': elicitation_handler,
                'log_handler': log_handler,
                'progress_handler': progress_handler,
                'message_handler': message_handler,
                'client_info': client_info,
                'roots': roots,
                'auth': auth,
                'verify': verify,
                'headers': headers,
                'http_client': http_client,
            }
            conflicts = [name for name, value in forwarded_values.items() if value is not None]
            # `init_timeout`/`read_timeout` use `_UNSET` as their default so we can detect "passed
            # explicitly" vs "default" without coupling to the literal default values.
            if init_timeout is not _UNSET:
                conflicts.append('init_timeout')
            if read_timeout is not _UNSET:
                conflicts.append('read_timeout')
            if conflicts:
                names = ', '.join(repr(n) for n in conflicts)
                raise ValueError(
                    f'Cannot pass {names} alongside a pre-built `fastmcp.Client` — '
                    'configure these on the Client itself instead.'
                )
            self.client = client
            self._user_message_handler = None
        else:
            if sampling_handler is not None and sampling_model is not None:
                raise ValueError('Pass either `sampling_model` or `sampling_handler`, not both.')
            if headers is not None and http_client is not None:
                raise ValueError(
                    '`headers` and `http_client` are mutually exclusive — set headers on the `http_client` instead.'
                )

            # Resolve sentinels to actual defaults now that the conflict check has run.
            if init_timeout is _UNSET:
                init_timeout = 5
            if read_timeout is _UNSET:
                read_timeout = 5 * 60

            transport = _build_transport(
                client,
                headers=headers,
                http_client=http_client,
                auth=auth,
                verify=verify,
                read_timeout=read_timeout,
            )
            resolved_sampling_handler = sampling_handler
            if resolved_sampling_handler is None and sampling_model is not None:
                resolved_sampling_handler = _build_sampling_handler(sampling_model)

            wrapped_message_handler = _build_message_handler(self, message_handler)

            self.client = FastMCPClient[Any](
                transport=transport,
                sampling_handler=resolved_sampling_handler,
                elicitation_handler=elicitation_handler,
                log_handler=log_handler,
                progress_handler=progress_handler,
                message_handler=wrapped_message_handler,
                client_info=client_info,
                init_timeout=init_timeout,
                timeout=read_timeout,
                roots=roots,
            )
            self._user_message_handler = message_handler

        self._id = id
        self.max_retries = max_retries
        self.tool_error_behavior = tool_error_behavior
        self.process_tool_call = process_tool_call
        self.cache_tools = cache_tools
        self.cache_resources = cache_resources
        self.include_instructions = include_instructions
        self.include_return_schema = include_return_schema
        self.sampling_model = sampling_model
        self.log_level = log_level

        self._server_info = None
        self._server_capabilities = None
        self._instructions = None
        self._cached_tools = None
        self._cached_resources = None
        self._running_count = 0
        self._exit_stack = None

    @property
    def id(self) -> str | None:
        return self._id

    @id.setter
    def id(self, value: str | None) -> None:
        self._id = value

    @property
    def label(self) -> str:
        if self.id:
            return super().label  # pragma: no cover
        return repr(self)

    @property
    def tool_name_conflict_hint(self) -> str:
        return 'Wrap the toolset with `.prefixed("...")` to disambiguate tool names from multiple MCP servers.'

    @property
    def server_info(self) -> mcp_types.Implementation:
        """The server-implementation info sent during initialization.

        Raises [`AttributeError`][AttributeError] when accessed before the toolset has been entered.
        """
        if self._server_info is None:
            raise AttributeError(f'`{self.__class__.__name__}.server_info` is only available after initialization.')
        return self._server_info

    @property
    def capabilities(self) -> ServerCapabilities:
        """The capabilities advertised by the server during initialization.

        Raises [`AttributeError`][AttributeError] when accessed before the toolset has been entered.
        """
        if self._server_capabilities is None:
            raise AttributeError(f'`{self.__class__.__name__}.capabilities` is only available after initialization.')
        return self._server_capabilities

    @property
    def instructions(self) -> str | None:
        """The instructions sent by the server during initialization.

        Raises [`AttributeError`][AttributeError] when accessed before the toolset has been entered.
        """
        if not self._initialized:
            raise AttributeError(f'`{self.__class__.__name__}.instructions` is only available after initialization.')
        return self._instructions

    @property
    def is_running(self) -> bool:
        """Whether the toolset is currently entered (the FastMCP session is open)."""
        return self._running_count > 0

    def set_sampling_model(self, model: models.Model) -> None:
        """Set the [`sampling_model`][pydantic_ai.mcp.MCPToolset.sampling_model] on an already-constructed toolset.

        Swaps both the public attribute and the underlying FastMCP client's sampling callback.
        Takes effect on the next session opened by the client; calls already in flight on an
        existing session continue using the previously configured handler.
        """
        self.sampling_model = model
        self.client.set_sampling_callback(_build_sampling_handler(model))  # pyright: ignore[reportUnknownMemberType]

    @property
    def _initialized(self) -> bool:
        return self._server_info is not None

    def _invalidate_tools_cache(self) -> None:
        self._cached_tools = None

    def _invalidate_resources_cache(self) -> None:
        self._cached_resources = None

    async def __aenter__(self) -> Self:
        async with self._enter_lock:
            if self._running_count == 0:
                # Build the exit stack inside an `async with` so any failure after
                # `enter_async_context(self.client)` cleans up the open session — only commit the
                # stack and write `_server_info`/`_server_capabilities`/`_instructions` to `self`
                # once initialization fully succeeds, so `_initialized` can't see stale data from a
                # session that got torn down mid-setup.
                async with AsyncExitStack() as exit_stack:
                    await exit_stack.enter_async_context(self.client)
                    init_result = self.client.initialize_result
                    assert init_result is not None, 'FastMCP Client initialization returned no result'
                    server_info = init_result.serverInfo
                    server_capabilities = ServerCapabilities.from_mcp_sdk(init_result.capabilities)
                    instructions = init_result.instructions
                    if self.log_level is not None:
                        await self.client.session.set_logging_level(self.log_level)
                    self._exit_stack = exit_stack.pop_all()
                    self._server_info = server_info
                    self._server_capabilities = server_capabilities
                    self._instructions = instructions
            self._running_count += 1
        return self

    async def __aexit__(self, *args: Any) -> bool | None:
        async with self._enter_lock:
            if self._running_count == 0:
                raise ValueError(f'`{self.__class__.__name__}.__aexit__` called more times than `__aenter__`')
            self._running_count -= 1
            if self._running_count == 0 and self._exit_stack is not None:
                await self._exit_stack.aclose()
                self._exit_stack = None
                self._server_info = None
                self._server_capabilities = None
                self._instructions = None
                self._cached_tools = None
                self._cached_resources = None
        return None

    async def get_instructions(self, ctx: RunContext[AgentDepsT]) -> messages.InstructionPart | None:
        """Return the server's instructions if `include_instructions` is enabled."""
        if not self.include_instructions:
            return None
        if not self._initialized or self._instructions is None:
            return None
        # Instructions are captured once during `__aenter__` and don't change across runs while
        # the toolset stays entered — so they're static from the agent's perspective, not dynamic.
        return messages.InstructionPart(content=self._instructions, dynamic=False)

    async def list_tools(self) -> list[mcp_types.Tool]:
        """Retrieve the tools currently exposed by the server.

        When [`cache_tools`][pydantic_ai.mcp.MCPToolset.cache_tools] is enabled (default), results
        are cached and invalidated by `notifications/tools/list_changed` or the toolset's last
        `__aexit__`.
        """
        if self.cache_tools and self._cached_tools is not None:
            return self._cached_tools
        async with self:
            tools = await self.client.list_tools()
            if self.cache_tools:
                self._cached_tools = tools
            return tools

    async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]:
        max_retries = self.max_retries if self.max_retries is not None else ctx.max_retries
        return {
            mcp_tool.name: ToolsetTool[AgentDepsT](
                toolset=self,
                tool_def=ToolDefinition(
                    name=mcp_tool.name,
                    description=mcp_tool.description,
                    parameters_json_schema=mcp_tool.inputSchema,
                    metadata={
                        'meta': mcp_tool.meta,
                        'annotations': mcp_tool.annotations.model_dump() if mcp_tool.annotations else None,
                    },
                    return_schema=mcp_tool.outputSchema or None,
                    include_return_schema=self.include_return_schema,
                ),
                max_retries=max_retries,
                args_validator=TOOL_SCHEMA_VALIDATOR,
            )
            for mcp_tool in await self.list_tools()
        }

    def tool_for_tool_def(self, tool_def: ToolDefinition) -> ToolsetTool[AgentDepsT]:
        return ToolsetTool[AgentDepsT](
            toolset=self,
            tool_def=tool_def,
            max_retries=self.max_retries if self.max_retries is not None else 1,
            args_validator=TOOL_SCHEMA_VALIDATOR,
        )

    async def direct_call_tool(
        self,
        name: str,
        args: dict[str, Any],
        *,
        metadata: dict[str, Any] | None = None,
    ) -> Any:
        """Call a tool on the server directly.

        Args:
            name: The name of the tool to call.
            args: The arguments to pass to the tool.
            metadata: Optional request-level `_meta` payload sent alongside the call.

        Raises:
            ModelRetry: If the tool errors and `tool_error_behavior='retry'` (the default).
            fastmcp.exceptions.ToolError: If the tool errors and `tool_error_behavior='error'`.
        """
        async with self:
            try:
                result: CallToolResult = await self.client.call_tool(name=name, arguments=args, meta=metadata)
            except ToolError as e:
                if self.tool_error_behavior == 'retry':
                    raise exceptions.ModelRetry(message=str(e)) from e
                raise

        # Prefer structured content if all parts are text (per the docs they contain the JSON-encoded
        # structured content for backward compatibility).
        # See https://github.com/modelcontextprotocol/python-sdk#structured-output
        if (structured := result.structured_content) and all(
            isinstance(part, mcp_types.TextContent) for part in result.content
        ):
            # The MCP SDK wraps primitives and generic types like list in a `result` key, but we want
            # the raw value returned by the tool function.
            if isinstance(structured, dict) and len(structured) == 1 and 'result' in structured:
                return structured['result']
            return structured

        return _map_mcp_tool_results(result.content)

    async def call_tool(
        self,
        name: str,
        tool_args: dict[str, Any],
        ctx: RunContext[Any],
        tool: ToolsetTool[Any],
    ) -> Any:
        if self.process_tool_call is not None:
            return await self.process_tool_call(ctx, self.direct_call_tool, name, tool_args)
        return await self.direct_call_tool(name, tool_args)

    async def list_resources(self) -> list[Resource]:
        """Retrieve the resources currently exposed by the server.

        When [`cache_resources`][pydantic_ai.mcp.MCPToolset.cache_resources] is enabled (default),
        results are cached and invalidated by `notifications/resources/list_changed` or the
        toolset's last `__aexit__`.

        Returns an empty list if the server does not advertise the `resources` capability.

        Raises:
            MCPError: If the server returns an error.
        """
        if self.cache_resources and self._cached_resources is not None:
            return self._cached_resources
        async with self:
            if not self.capabilities.resources:
                return []
            try:
                mcp_resources = await self.client.list_resources()
            except mcp_exceptions.McpError as e:
                raise MCPError.from_mcp_sdk(e) from e
            resources = [Resource.from_mcp_sdk(r) for r in mcp_resources]
            if self.cache_resources:
                self._cached_resources = resources
            return resources

    async def list_resource_templates(self) -> list[ResourceTemplate]:
        """Retrieve the resource templates currently exposed by the server.

        Returns an empty list if the server does not advertise the `resources` capability.

        Raises:
            MCPError: If the server returns an error.
        """
        async with self:
            if not self.capabilities.resources:
                return []
            try:
                mcp_templates = await self.client.list_resource_templates()
            except mcp_exceptions.McpError as e:
                raise MCPError.from_mcp_sdk(e) from e
        return [ResourceTemplate.from_mcp_sdk(t) for t in mcp_templates]

    @overload
    async def read_resource(self, uri: str) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ...

    @overload
    async def read_resource(
        self, uri: Resource
    ) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ...

    async def read_resource(
        self, uri: str | Resource
    ) -> str | messages.BinaryContent | list[str | messages.BinaryContent]:
        """Read the contents of a specific resource by URI.

        Args:
            uri: The URI of the resource to read, or a [`Resource`][pydantic_ai.mcp.Resource] object.

        Returns:
            The resource contents — a single value if the resource has one content item, or a list
            otherwise. Text content is returned as `str`, binary content as
            [`BinaryContent`][pydantic_ai.messages.BinaryContent].

        Raises:
            MCPError: If the server returns an error.
        """
        resource_uri = uri if isinstance(uri, str) else uri.uri
        async with self:
            try:
                contents = await self.client.read_resource(AnyUrl(resource_uri))
            except mcp_exceptions.McpError as e:
                raise MCPError.from_mcp_sdk(e) from e

        return (
            _resource_content_to_pai(contents[0])
            if len(contents) == 1
            else [_resource_content_to_pai(c) for c in contents]
        )

    def __repr__(self) -> str:
        repr_args = [f'client={self.client!r}']
        if self._id is not None:
            repr_args.append(f'id={self._id!r}')
        return f'{self.__class__.__name__}({", ".join(repr_args)})'

    def __eq__(self, value: object, /) -> bool:
        return isinstance(value, MCPToolset) and self._id == value._id and self.client is value.client

    def __hash__(self) -> int:
        return hash((self._id, id(self.client)))

client instance-attribute

client: Client[Any]

The underlying FastMCP Client. Always normalized to a fastmcp.Client regardless of how the toolset was constructed.

__init__

__init__(
    client: MCPToolsetClient,
    *,
    id: str | None = None,
    max_retries: int | None = None,
    tool_error_behavior: Literal[
        "retry", "error"
    ] = "retry",
    process_tool_call: ProcessToolCallback | None = None,
    cache_tools: bool = True,
    cache_resources: bool = True,
    include_instructions: bool = False,
    include_return_schema: bool | None = None,
    sampling_model: Model | None = None,
    sampling_handler: (
        SamplingHandler[Any, Any] | None
    ) = None,
    elicitation_handler: (
        ElicitationHandler[Any, Any] | None
    ) = None,
    log_handler: LogHandler | None = None,
    log_level: LoggingLevel | None = None,
    progress_handler: ProgressHandler | None = None,
    message_handler: MessageHandlerT | None = None,
    client_info: Implementation | None = None,
    init_timeout: float | None = _UNSET,
    read_timeout: float | None = _UNSET,
    roots: RootsList | RootsHandler[Any] | None = None,
    auth: Auth | Literal["oauth"] | str | None = None,
    verify: SSLContext | bool | str | None = None,
    headers: dict[str, str] | None = None,
    http_client: AsyncClient | None = None
)

Build a new MCPToolset.

Parameters:

Name Type Description Default
client MCPToolsetClient

How to connect to the MCP server. See the class docstring for accepted shapes.

required
id str | None

An optional unique identifier for this toolset. Required for use in durable execution environments like Temporal or DBOS, where it identifies the toolset's activities/steps within a workflow.

None
max_retries int | None

Maximum number of times a tool call may be retried after a ModelRetry. None inherits the agent's retry count at runtime.

None
tool_error_behavior Literal['retry', 'error']

'retry' (default) raises ModelRetry on tool errors so the model can self-correct; 'error' propagates the underlying exception.

'retry'
process_tool_call ProcessToolCallback | None

Hook to wrap tool calls. See ProcessToolCallback.

None
cache_tools bool

Whether to cache the list of tools. See MCPToolset.cache_tools.

True
cache_resources bool

Whether to cache the list of resources. See MCPToolset.cache_resources.

True
include_instructions bool

Whether to include the server's instructions in the agent's instructions. See MCPToolset.include_instructions.

False
include_return_schema bool | None

Whether to include return schemas in tool definitions. See MCPToolset.include_return_schema.

None
sampling_model Model | None

A Pydantic AI model the server may sample from. Mutually exclusive with sampling_handler.

None
sampling_handler SamplingHandler[Any, Any] | None

A FastMCP-shaped sampling handler. Use for full control over the sampling response.

None
elicitation_handler ElicitationHandler[Any, Any] | None

A FastMCP-shaped elicitation handler that receives MCP elicitation/create requests from the server.

None
log_handler LogHandler | None

A FastMCP-shaped log handler that receives log messages from the server.

None
log_level LoggingLevel | None

Log level requested from the server via logging/setLevel after initialization.

None
progress_handler ProgressHandler | None

A FastMCP-shaped progress handler.

None
message_handler MessageHandlerT | None

A FastMCP-shaped message handler called for every server-sent message. Pydantic AI installs its own message handler internally to invalidate caches on list_changed notifications; if you provide one, both run (yours after ours).

None
client_info Implementation | None

Information describing the MCP client implementation, sent to the server during initialization.

None
init_timeout float | None

Timeout in seconds for the initial connection and initialize handshake.

_UNSET
read_timeout float | None

Maximum time in seconds to wait for new messages on the long-lived connection. Defaults to 5 minutes.

_UNSET
roots RootsList | RootsHandler[Any] | None

Filesystem roots advertised to the server.

None
auth Auth | Literal['oauth'] | str | None

HTTP authentication for HTTP transports — an httpx.Auth, the literal string 'oauth' to enable FastMCP's OAuth flow, or a bearer-token string.

None
verify SSLContext | bool | str | None

SSL verification mode for HTTP transports — an ssl.SSLContext, a CA bundle path string, or a bool.

None
headers dict[str, str] | None

Extra HTTP headers for HTTP transports. Mutually exclusive with http_client.

None
http_client AsyncClient | None

A pre-configured httpx.AsyncClient to use for HTTP transports — useful for self-signed certificates or custom connection pooling. Mutually exclusive with headers.

None

Raises:

Type Description
ValueError

If a pre-built fastmcp.Client is passed alongside any of the kwargs that would otherwise build a default Client (sampling, elicitation, headers, etc.), or if sampling_model and sampling_handler are both passed, or if headers and http_client are both passed.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
def __init__(
    self,
    client: MCPToolsetClient,
    *,
    # Pydantic AI-layer config
    id: str | None = None,
    max_retries: int | None = None,
    tool_error_behavior: Literal['retry', 'error'] = 'retry',
    process_tool_call: ProcessToolCallback | None = None,
    cache_tools: bool = True,
    cache_resources: bool = True,
    include_instructions: bool = False,
    include_return_schema: bool | None = None,
    # Sampling — high-level shortcut and low-level escape hatch
    sampling_model: models.Model | None = None,
    sampling_handler: SamplingHandler[Any, Any] | None = None,
    # MCP protocol kwargs (forwarded to a default FastMCP Client when one isn't passed)
    elicitation_handler: ElicitationHandler[Any, Any] | None = None,
    log_handler: LogHandler | None = None,
    log_level: mcp_types.LoggingLevel | None = None,
    progress_handler: ProgressHandler | None = None,
    message_handler: MessageHandlerT | None = None,
    client_info: mcp_types.Implementation | None = None,
    init_timeout: float | None = _UNSET,
    read_timeout: float | None = _UNSET,
    roots: RootsList | RootsHandler[Any] | None = None,
    # HTTP-specific (only used when constructing a default transport from a URL)
    auth: httpx.Auth | Literal['oauth'] | str | None = None,
    verify: ssl.SSLContext | bool | str | None = None,
    headers: dict[str, str] | None = None,
    http_client: httpx.AsyncClient | None = None,
):
    """Build a new `MCPToolset`.

    Args:
        client: How to connect to the MCP server. See the class docstring for accepted shapes.
        id: An optional unique identifier for this toolset. Required for use in durable execution
            environments like Temporal or DBOS, where it identifies the toolset's activities/steps
            within a workflow.
        max_retries: Maximum number of times a tool call may be retried after a `ModelRetry`.
            `None` inherits the agent's retry count at runtime.
        tool_error_behavior: `'retry'` (default) raises
            [`ModelRetry`][pydantic_ai.exceptions.ModelRetry] on tool errors so the model can
            self-correct; `'error'` propagates the underlying exception.
        process_tool_call: Hook to wrap tool calls. See
            [`ProcessToolCallback`][pydantic_ai.mcp.ProcessToolCallback].
        cache_tools: Whether to cache the list of tools. See
            [`MCPToolset.cache_tools`][pydantic_ai.mcp.MCPToolset.cache_tools].
        cache_resources: Whether to cache the list of resources. See
            [`MCPToolset.cache_resources`][pydantic_ai.mcp.MCPToolset.cache_resources].
        include_instructions: Whether to include the server's instructions in the agent's
            instructions. See
            [`MCPToolset.include_instructions`][pydantic_ai.mcp.MCPToolset.include_instructions].
        include_return_schema: Whether to include return schemas in tool definitions. See
            [`MCPToolset.include_return_schema`][pydantic_ai.mcp.MCPToolset.include_return_schema].
        sampling_model: A Pydantic AI model the server may sample from. Mutually exclusive with
            `sampling_handler`.
        sampling_handler: A FastMCP-shaped sampling handler. Use for full control over the
            sampling response.
        elicitation_handler: A FastMCP-shaped elicitation handler that receives MCP
            `elicitation/create` requests from the server.
        log_handler: A FastMCP-shaped log handler that receives log messages from the server.
        log_level: Log level requested from the server via `logging/setLevel` after
            initialization.
        progress_handler: A FastMCP-shaped progress handler.
        message_handler: A FastMCP-shaped message handler called for every server-sent message.
            Pydantic AI installs its own message handler internally to invalidate caches on
            `list_changed` notifications; if you provide one, both run (yours after ours).
        client_info: Information describing the MCP client implementation, sent to the server
            during initialization.
        init_timeout: Timeout in seconds for the initial connection and `initialize` handshake.
        read_timeout: Maximum time in seconds to wait for new messages on the long-lived
            connection. Defaults to 5 minutes.
        roots: Filesystem roots advertised to the server.
        auth: HTTP authentication for HTTP transports — an `httpx.Auth`, the literal string
            `'oauth'` to enable FastMCP's OAuth flow, or a bearer-token string.
        verify: SSL verification mode for HTTP transports — an `ssl.SSLContext`, a CA bundle
            path string, or a bool.
        headers: Extra HTTP headers for HTTP transports. Mutually exclusive with `http_client`.
        http_client: A pre-configured `httpx.AsyncClient` to use for HTTP transports — useful
            for self-signed certificates or custom connection pooling. Mutually exclusive with
            `headers`.

    Raises:
        ValueError: If a pre-built `fastmcp.Client` is passed alongside any of the kwargs that
            would otherwise build a default Client (sampling, elicitation, headers, etc.), or
            if `sampling_model` and `sampling_handler` are both passed, or if `headers` and
            `http_client` are both passed.
    """
    if isinstance(client, FastMCPClient):
        forwarded_values: dict[str, Any] = {
            'sampling_handler': sampling_handler,
            'sampling_model': sampling_model,
            'elicitation_handler': elicitation_handler,
            'log_handler': log_handler,
            'progress_handler': progress_handler,
            'message_handler': message_handler,
            'client_info': client_info,
            'roots': roots,
            'auth': auth,
            'verify': verify,
            'headers': headers,
            'http_client': http_client,
        }
        conflicts = [name for name, value in forwarded_values.items() if value is not None]
        # `init_timeout`/`read_timeout` use `_UNSET` as their default so we can detect "passed
        # explicitly" vs "default" without coupling to the literal default values.
        if init_timeout is not _UNSET:
            conflicts.append('init_timeout')
        if read_timeout is not _UNSET:
            conflicts.append('read_timeout')
        if conflicts:
            names = ', '.join(repr(n) for n in conflicts)
            raise ValueError(
                f'Cannot pass {names} alongside a pre-built `fastmcp.Client` — '
                'configure these on the Client itself instead.'
            )
        self.client = client
        self._user_message_handler = None
    else:
        if sampling_handler is not None and sampling_model is not None:
            raise ValueError('Pass either `sampling_model` or `sampling_handler`, not both.')
        if headers is not None and http_client is not None:
            raise ValueError(
                '`headers` and `http_client` are mutually exclusive — set headers on the `http_client` instead.'
            )

        # Resolve sentinels to actual defaults now that the conflict check has run.
        if init_timeout is _UNSET:
            init_timeout = 5
        if read_timeout is _UNSET:
            read_timeout = 5 * 60

        transport = _build_transport(
            client,
            headers=headers,
            http_client=http_client,
            auth=auth,
            verify=verify,
            read_timeout=read_timeout,
        )
        resolved_sampling_handler = sampling_handler
        if resolved_sampling_handler is None and sampling_model is not None:
            resolved_sampling_handler = _build_sampling_handler(sampling_model)

        wrapped_message_handler = _build_message_handler(self, message_handler)

        self.client = FastMCPClient[Any](
            transport=transport,
            sampling_handler=resolved_sampling_handler,
            elicitation_handler=elicitation_handler,
            log_handler=log_handler,
            progress_handler=progress_handler,
            message_handler=wrapped_message_handler,
            client_info=client_info,
            init_timeout=init_timeout,
            timeout=read_timeout,
            roots=roots,
        )
        self._user_message_handler = message_handler

    self._id = id
    self.max_retries = max_retries
    self.tool_error_behavior = tool_error_behavior
    self.process_tool_call = process_tool_call
    self.cache_tools = cache_tools
    self.cache_resources = cache_resources
    self.include_instructions = include_instructions
    self.include_return_schema = include_return_schema
    self.sampling_model = sampling_model
    self.log_level = log_level

    self._server_info = None
    self._server_capabilities = None
    self._instructions = None
    self._cached_tools = None
    self._cached_resources = None
    self._running_count = 0
    self._exit_stack = None

max_retries instance-attribute

max_retries: int | None = max_retries

Maximum number of times a tool call may be retried after a ModelRetry.

None (default) inherits the agent's retry count at runtime. Set explicitly to override.

tool_error_behavior instance-attribute

tool_error_behavior: Literal["retry", "error"] = (
    tool_error_behavior
)

How to handle tool errors raised by the server.

'retry' (default) raises ModelRetry so the model can self-correct; 'error' propagates the underlying fastmcp.exceptions.ToolError to the caller.

process_tool_call instance-attribute

process_tool_call: ProcessToolCallback | None = (
    process_tool_call
)

Hook to wrap tool calls — useful for adding request-level metadata, custom retry policies, or telemetry. See ProcessToolCallback.

cache_tools instance-attribute

cache_tools: bool = cache_tools

Whether to cache the list of tools across get_tools() calls.

When enabled (default), tools are fetched once and cached until either:

  • The server sends a notifications/tools/list_changed notification
  • The toolset is fully exited (last __aexit__ matches the first __aenter__)

Set to False for servers that change tools dynamically without sending notifications, or when passing a pre-built FastMCP Client (the cache-invalidation message handler isn't installed in that case, so caches are only invalidated by session close).

cache_resources instance-attribute

cache_resources: bool = cache_resources

Whether to cache the list of resources across list_resources() calls.

Same semantics as cache_tools but for notifications/resources/list_changed notifications.

include_instructions instance-attribute

include_instructions: bool = include_instructions

Whether to include the server's initialize instructions string in the agent's instruction set.

Defaults to False for backward compatibility. When True, the instructions returned by the server during initialization are added to the agent's instructions.

include_return_schema instance-attribute

include_return_schema: bool | None = include_return_schema

Whether to include each tool's outputSchema in the schema sent to the model.

When None (the default), defaults to False unless the IncludeToolReturnSchemas capability is used.

sampling_model instance-attribute

sampling_model: Model | None = sampling_model

A Pydantic AI model that the server may sample from via the MCP sampling/createMessage flow.

When set (and no explicit sampling_handler is passed), Pydantic AI builds a sampling handler that delegates to this model with the request's maxTokens/temperature/stopSequences settings applied. If both sampling_model and sampling_handler are passed, an error is raised.

log_level instance-attribute

log_level: LoggingLevel | None = log_level

Log level requested from the server via logging/setLevel after initialization.

None (default) leaves the server's default log level alone. Combine with log_handler to receive log messages.

server_info property

server_info: Implementation

The server-implementation info sent during initialization.

Raises AttributeError when accessed before the toolset has been entered.

capabilities property

capabilities: ServerCapabilities

The capabilities advertised by the server during initialization.

Raises AttributeError when accessed before the toolset has been entered.

instructions property

instructions: str | None

The instructions sent by the server during initialization.

Raises AttributeError when accessed before the toolset has been entered.

is_running property

is_running: bool

Whether the toolset is currently entered (the FastMCP session is open).

set_sampling_model

set_sampling_model(model: Model) -> None

Set the sampling_model on an already-constructed toolset.

Swaps both the public attribute and the underlying FastMCP client's sampling callback. Takes effect on the next session opened by the client; calls already in flight on an existing session continue using the previously configured handler.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
754
755
756
757
758
759
760
761
762
def set_sampling_model(self, model: models.Model) -> None:
    """Set the [`sampling_model`][pydantic_ai.mcp.MCPToolset.sampling_model] on an already-constructed toolset.

    Swaps both the public attribute and the underlying FastMCP client's sampling callback.
    Takes effect on the next session opened by the client; calls already in flight on an
    existing session continue using the previously configured handler.
    """
    self.sampling_model = model
    self.client.set_sampling_callback(_build_sampling_handler(model))  # pyright: ignore[reportUnknownMemberType]

get_instructions async

get_instructions(
    ctx: RunContext[AgentDepsT],
) -> InstructionPart | None

Return the server's instructions if include_instructions is enabled.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
813
814
815
816
817
818
819
820
821
async def get_instructions(self, ctx: RunContext[AgentDepsT]) -> messages.InstructionPart | None:
    """Return the server's instructions if `include_instructions` is enabled."""
    if not self.include_instructions:
        return None
    if not self._initialized or self._instructions is None:
        return None
    # Instructions are captured once during `__aenter__` and don't change across runs while
    # the toolset stays entered — so they're static from the agent's perspective, not dynamic.
    return messages.InstructionPart(content=self._instructions, dynamic=False)

list_tools async

list_tools() -> list[Tool]

Retrieve the tools currently exposed by the server.

When cache_tools is enabled (default), results are cached and invalidated by notifications/tools/list_changed or the toolset's last __aexit__.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
823
824
825
826
827
828
829
830
831
832
833
834
835
836
async def list_tools(self) -> list[mcp_types.Tool]:
    """Retrieve the tools currently exposed by the server.

    When [`cache_tools`][pydantic_ai.mcp.MCPToolset.cache_tools] is enabled (default), results
    are cached and invalidated by `notifications/tools/list_changed` or the toolset's last
    `__aexit__`.
    """
    if self.cache_tools and self._cached_tools is not None:
        return self._cached_tools
    async with self:
        tools = await self.client.list_tools()
        if self.cache_tools:
            self._cached_tools = tools
        return tools

direct_call_tool async

direct_call_tool(
    name: str,
    args: dict[str, Any],
    *,
    metadata: dict[str, Any] | None = None
) -> Any

Call a tool on the server directly.

Parameters:

Name Type Description Default
name str

The name of the tool to call.

required
args dict[str, Any]

The arguments to pass to the tool.

required
metadata dict[str, Any] | None

Optional request-level _meta payload sent alongside the call.

None

Raises:

Type Description
ModelRetry

If the tool errors and tool_error_behavior='retry' (the default).

ToolError

If the tool errors and tool_error_behavior='error'.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
async def direct_call_tool(
    self,
    name: str,
    args: dict[str, Any],
    *,
    metadata: dict[str, Any] | None = None,
) -> Any:
    """Call a tool on the server directly.

    Args:
        name: The name of the tool to call.
        args: The arguments to pass to the tool.
        metadata: Optional request-level `_meta` payload sent alongside the call.

    Raises:
        ModelRetry: If the tool errors and `tool_error_behavior='retry'` (the default).
        fastmcp.exceptions.ToolError: If the tool errors and `tool_error_behavior='error'`.
    """
    async with self:
        try:
            result: CallToolResult = await self.client.call_tool(name=name, arguments=args, meta=metadata)
        except ToolError as e:
            if self.tool_error_behavior == 'retry':
                raise exceptions.ModelRetry(message=str(e)) from e
            raise

    # Prefer structured content if all parts are text (per the docs they contain the JSON-encoded
    # structured content for backward compatibility).
    # See https://github.com/modelcontextprotocol/python-sdk#structured-output
    if (structured := result.structured_content) and all(
        isinstance(part, mcp_types.TextContent) for part in result.content
    ):
        # The MCP SDK wraps primitives and generic types like list in a `result` key, but we want
        # the raw value returned by the tool function.
        if isinstance(structured, dict) and len(structured) == 1 and 'result' in structured:
            return structured['result']
        return structured

    return _map_mcp_tool_results(result.content)

list_resources async

list_resources() -> list[Resource]

Retrieve the resources currently exposed by the server.

When cache_resources is enabled (default), results are cached and invalidated by notifications/resources/list_changed or the toolset's last __aexit__.

Returns an empty list if the server does not advertise the resources capability.

Raises:

Type Description
MCPError

If the server returns an error.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
async def list_resources(self) -> list[Resource]:
    """Retrieve the resources currently exposed by the server.

    When [`cache_resources`][pydantic_ai.mcp.MCPToolset.cache_resources] is enabled (default),
    results are cached and invalidated by `notifications/resources/list_changed` or the
    toolset's last `__aexit__`.

    Returns an empty list if the server does not advertise the `resources` capability.

    Raises:
        MCPError: If the server returns an error.
    """
    if self.cache_resources and self._cached_resources is not None:
        return self._cached_resources
    async with self:
        if not self.capabilities.resources:
            return []
        try:
            mcp_resources = await self.client.list_resources()
        except mcp_exceptions.McpError as e:
            raise MCPError.from_mcp_sdk(e) from e
        resources = [Resource.from_mcp_sdk(r) for r in mcp_resources]
        if self.cache_resources:
            self._cached_resources = resources
        return resources

list_resource_templates async

list_resource_templates() -> list[ResourceTemplate]

Retrieve the resource templates currently exposed by the server.

Returns an empty list if the server does not advertise the resources capability.

Raises:

Type Description
MCPError

If the server returns an error.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
async def list_resource_templates(self) -> list[ResourceTemplate]:
    """Retrieve the resource templates currently exposed by the server.

    Returns an empty list if the server does not advertise the `resources` capability.

    Raises:
        MCPError: If the server returns an error.
    """
    async with self:
        if not self.capabilities.resources:
            return []
        try:
            mcp_templates = await self.client.list_resource_templates()
        except mcp_exceptions.McpError as e:
            raise MCPError.from_mcp_sdk(e) from e
    return [ResourceTemplate.from_mcp_sdk(t) for t in mcp_templates]

read_resource async

read_resource(
    uri: str,
) -> str | BinaryContent | list[str | BinaryContent]
read_resource(
    uri: Resource,
) -> str | BinaryContent | list[str | BinaryContent]
read_resource(
    uri: str | Resource,
) -> str | BinaryContent | list[str | BinaryContent]

Read the contents of a specific resource by URI.

Parameters:

Name Type Description Default
uri str | Resource

The URI of the resource to read, or a Resource object.

required

Returns:

Type Description
str | BinaryContent | list[str | BinaryContent]

The resource contents — a single value if the resource has one content item, or a list

str | BinaryContent | list[str | BinaryContent]

otherwise. Text content is returned as str, binary content as

str | BinaryContent | list[str | BinaryContent]

Raises:

Type Description
MCPError

If the server returns an error.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
async def read_resource(
    self, uri: str | Resource
) -> str | messages.BinaryContent | list[str | messages.BinaryContent]:
    """Read the contents of a specific resource by URI.

    Args:
        uri: The URI of the resource to read, or a [`Resource`][pydantic_ai.mcp.Resource] object.

    Returns:
        The resource contents — a single value if the resource has one content item, or a list
        otherwise. Text content is returned as `str`, binary content as
        [`BinaryContent`][pydantic_ai.messages.BinaryContent].

    Raises:
        MCPError: If the server returns an error.
    """
    resource_uri = uri if isinstance(uri, str) else uri.uri
    async with self:
        try:
            contents = await self.client.read_resource(AnyUrl(resource_uri))
        except mcp_exceptions.McpError as e:
            raise MCPError.from_mcp_sdk(e) from e

    return (
        _resource_content_to_pai(contents[0])
        if len(contents) == 1
        else [_resource_content_to_pai(c) for c in contents]
    )

load_mcp_toolsets

load_mcp_toolsets(
    config_path: str | Path,
) -> list[AbstractToolset[Any]]

Load MCPToolsets from a configuration file.

The configuration file uses the same mcpServers JSON shape as Claude Desktop, Cursor, and the MCP specification. Each server entry produces one MCPToolset, wrapped in a PrefixedToolset using the server's name as prefix to disambiguate tools across multiple servers.

Environment variables can be referenced in the configuration file using:

  • ${VAR_NAME} syntax — expands to the value of VAR_NAME, raises if not defined
  • ${VAR_NAME:-default} syntax — expands to VAR_NAME if set, otherwise the default

Parameters:

Name Type Description Default
config_path str | Path

Path to the JSON configuration file.

required

Returns:

Type Description
list[AbstractToolset[Any]]

A list of toolsets, one per server in the config file, each prefixed with the server name.

Raises:

Type Description
FileNotFoundError

If the configuration file does not exist.

ValidationError

If the configuration file does not match the schema.

ValueError

If an environment variable referenced in the configuration is not defined and no default is provided.

Source code in pydantic_ai_slim/pydantic_ai/mcp.py
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
def load_mcp_toolsets(config_path: str | Path) -> list[AbstractToolset[Any]]:
    """Load `MCPToolset`s from a configuration file.

    The configuration file uses the same `mcpServers` JSON shape as Claude Desktop, Cursor, and the
    MCP specification. Each server entry produces one [`MCPToolset`][pydantic_ai.mcp.MCPToolset],
    wrapped in a [`PrefixedToolset`][pydantic_ai.toolsets.PrefixedToolset] using the server's name
    as prefix to disambiguate tools across multiple servers.

    Environment variables can be referenced in the configuration file using:

    - `${VAR_NAME}` syntax — expands to the value of `VAR_NAME`, raises if not defined
    - `${VAR_NAME:-default}` syntax — expands to `VAR_NAME` if set, otherwise the default

    Args:
        config_path: Path to the JSON configuration file.

    Returns:
        A list of toolsets, one per server in the config file, each prefixed with the server name.

    Raises:
        FileNotFoundError: If the configuration file does not exist.
        ValidationError: If the configuration file does not match the schema.
        ValueError: If an environment variable referenced in the configuration is not defined and
            no default is provided.
    """
    config_path = Path(config_path)
    if not config_path.exists():
        raise FileNotFoundError(f'Config file {config_path} not found')

    config_data = pydantic_core.from_json(config_path.read_bytes())
    expanded_config_data = _expand_env_vars(config_data)
    if not isinstance(expanded_config_data, dict):
        raise ValueError(f'Expected JSON object at root of {config_path}, got {type(expanded_config_data).__name__}')
    servers = cast(dict[str, Any], expanded_config_data).get('mcpServers')
    if not isinstance(servers, dict):
        raise ValueError(f'Expected `mcpServers` object in {config_path}')

    toolsets: list[AbstractToolset[Any]] = []
    for name, server in cast(dict[str, Any], servers).items():
        if 'command' in server:
            transport = StdioTransport(
                command=server['command'],
                args=list(server.get('args') or []),
                env=server.get('env'),
                cwd=str(server['cwd']) if server.get('cwd') is not None else None,
            )
            toolset = MCPToolset(transport, id=name)
        elif 'url' in server:
            toolset = MCPToolset(server['url'], id=name, headers=server.get('headers'))
        else:
            raise ValueError(f'MCP server config {name!r} must have either `command` or `url`')
        toolsets.append(toolset.prefixed(name))

    return toolsets