-
Couldn't load subscription status.
- Fork 1.3k
Feature: Minimal MCP client Resources support #3024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 12 commits
9fde764
a7404a2
ecea466
1886299
dfb54db
f52bb71
232ff04
38675da
317416c
67f9b07
e6cb086
b8424d8
50cab26
6857ef2
18743cd
4e5d627
6f3a87a
72d66ac
3000511
30f4e7f
456e714
3d95521
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,12 @@ | ||
| import base64 | ||
| from abc import ABC | ||
| from collections.abc import Sequence | ||
| from typing import Literal | ||
| from dataclasses import dataclass | ||
| from typing import Annotated, Any, Literal | ||
|
|
||
| from . import exceptions, messages | ||
| from pydantic import Field | ||
|
|
||
| from . import _utils, exceptions, messages | ||
|
|
||
| try: | ||
| from mcp import types as mcp_types | ||
|
|
@@ -13,6 +17,63 @@ | |
| ) from _import_error | ||
|
|
||
|
|
||
| @dataclass(repr=False, kw_only=True) | ||
| class ResourceAnnotations: | ||
| """Additional properties describing MCP entities.""" | ||
|
|
||
| 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 | ||
|
|
||
|
|
||
| @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.""" | ||
|
|
||
| meta: dict[str, Any] | None = None | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we call this |
||
| """Optional metadata for the resource.""" | ||
|
|
||
| __repr__ = _utils.dataclasses_no_defaults_repr | ||
|
|
||
|
|
||
| @dataclass(repr=False, kw_only=True) | ||
| class Resource(BaseResource): | ||
| """A resource that can be read from an MCP server.""" | ||
|
|
||
| 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.""" | ||
|
|
||
|
|
||
| @dataclass(repr=False, kw_only=True) | ||
| class ResourceTemplate(BaseResource): | ||
| """A template for parameterized resources on an MCP server.""" | ||
|
|
||
| uri_template: str | ||
| """URI template (RFC 6570) for constructing resource URIs.""" | ||
|
|
||
|
|
||
| def map_from_mcp_params(params: mcp_types.CreateMessageRequestParams) -> list[messages.ModelMessage]: | ||
| """Convert from MCP create message request parameters to pydantic-ai messages.""" | ||
| pai_messages: list[messages.ModelMessage] = [] | ||
|
|
@@ -121,3 +182,38 @@ def map_from_sampling_content( | |
| return messages.TextPart(content=content.text) | ||
| else: | ||
| raise NotImplementedError('Image and Audio responses in sampling are not yet supported') | ||
|
|
||
|
|
||
| def map_from_mcp_resource(mcp_resource: mcp_types.Resource) -> Resource: | ||
fennb marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """Convert from MCP Resource to native Pydantic AI Resource.""" | ||
| return Resource( | ||
| 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=( | ||
fennb marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ResourceAnnotations(audience=mcp_resource.annotations.audience, priority=mcp_resource.annotations.priority) | ||
| if mcp_resource.annotations | ||
| else None | ||
| ), | ||
| meta=mcp_resource.meta, | ||
| ) | ||
|
|
||
|
|
||
| def map_from_mcp_resource_template(mcp_template: mcp_types.ResourceTemplate) -> ResourceTemplate: | ||
| """Convert from MCP ResourceTemplate to native Pydantic AI ResourceTemplate.""" | ||
| return ResourceTemplate( | ||
| uri_template=mcp_template.uriTemplate, | ||
| name=mcp_template.name, | ||
| title=mcp_template.title, | ||
| description=mcp_template.description, | ||
| mime_type=mcp_template.mimeType, | ||
| annotations=( | ||
| ResourceAnnotations(audience=mcp_template.annotations.audience, priority=mcp_template.annotations.priority) | ||
| if mcp_template.annotations | ||
| else None | ||
| ), | ||
| meta=mcp_template.meta, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,13 +10,13 @@ | |
| from dataclasses import field, replace | ||
| from datetime import timedelta | ||
| from pathlib import Path | ||
| from typing import Annotated, Any | ||
| from typing import Annotated, Any, overload | ||
|
|
||
| import anyio | ||
| import httpx | ||
| import pydantic_core | ||
| from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream | ||
| from pydantic import BaseModel, Discriminator, Field, Tag | ||
| from pydantic import AnyUrl, BaseModel, Discriminator, Field, Tag | ||
| from pydantic_core import CoreSchema, core_schema | ||
| from typing_extensions import Self, assert_never, deprecated | ||
|
|
||
|
|
@@ -303,6 +303,52 @@ def tool_for_tool_def(self, tool_def: ToolDefinition) -> ToolsetTool[Any]: | |
| args_validator=TOOL_SCHEMA_VALIDATOR, | ||
| ) | ||
|
|
||
| async def list_resources(self) -> list[_mcp.Resource]: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we return these types, they should be public! |
||
| """Retrieve resources that are currently present on the server. | ||
|
|
||
| Note: | ||
| - We don't cache resources as they might change. | ||
| - We also don't subscribe to resource changes to avoid complexity. | ||
| """ | ||
| async with self: # Ensure server is running | ||
| result = await self._client.list_resources() | ||
| return [_mcp.map_from_mcp_resource(r) for r in result.resources] | ||
|
|
||
| async def list_resource_templates(self) -> list[_mcp.ResourceTemplate]: | ||
| """Retrieve resource templates that are currently present on the server.""" | ||
| async with self: # Ensure server is running | ||
| result = await self._client.list_resource_templates() | ||
| return [_mcp.map_from_mcp_resource_template(t) for t in result.resourceTemplates] | ||
|
|
||
| @overload | ||
| async def read_resource(self, uri: str) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ... | ||
|
|
||
| @overload | ||
| async def read_resource( | ||
| self, uri: _mcp.Resource | ||
| ) -> str | messages.BinaryContent | list[str | messages.BinaryContent]: ... | ||
|
|
||
| async def read_resource( | ||
| self, uri: str | _mcp.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 object. | ||
|
|
||
| Returns: | ||
| The resource contents. If the resource has a single content item, returns that item directly. | ||
| If the resource has multiple content items, returns a list of items. | ||
| """ | ||
| resource_uri = uri if isinstance(uri, str) else uri.uri | ||
| async with self: # Ensure server is running | ||
| result = await self._client.read_resource(AnyUrl(resource_uri)) | ||
| return ( | ||
| self._get_content(result.contents[0]) | ||
| if len(result.contents) == 1 | ||
| else [self._get_content(resource) for resource in result.contents] | ||
| ) | ||
|
|
||
| async def __aenter__(self) -> Self: | ||
| """Enter the MCP server context. | ||
|
|
||
|
|
@@ -397,12 +443,7 @@ async def _map_tool_result_part( | |
| resource = part.resource | ||
| return self._get_content(resource) | ||
| elif isinstance(part, mcp_types.ResourceLink): | ||
| resource_result: mcp_types.ReadResourceResult = await self._client.read_resource(part.uri) | ||
| return ( | ||
| self._get_content(resource_result.contents[0]) | ||
| if len(resource_result.contents) == 1 | ||
| else [self._get_content(resource) for resource in resource_result.contents] | ||
| ) | ||
| return await self.read_resource(str(part.uri)) | ||
| else: | ||
| assert_never(part) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's make it extra explicit that resources are not automatically shared with the LLM, unless a tool returns a resource link.