Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/mcp/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ async def sampling_callback(
annotations=None,
meta=None,
),
meta=None,
)
]
"""
Expand Down
1 change: 0 additions & 1 deletion examples/pydantic_ai_examples/weather_agent_gradio.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ def select_data(message: gr.SelectData) -> str:
past_messages = gr.State([])
chatbot = gr.Chatbot(
label='Packing Assistant',
type='messages',
avatar_images=(None, 'https://ai.pydantic.dev/img/logo-white.svg'),
examples=[
{'text': 'What is the weather like in Miami?'},
Expand Down
4 changes: 2 additions & 2 deletions examples/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ dependencies = [
"rich>=13.9.2",
"uvicorn>=0.32.0",
"devtools>=0.12.2",
"gradio>=5.9.0",
"mcp[cli]>=1.4.1",
"gradio>=5.31.0",
"mcp[cli]>=1.25.0",
"modal>=1.0.4",
"duckdb>=1.3.2",
"datasets>=4.0.0",
Expand Down
16 changes: 13 additions & 3 deletions pydantic_ai_slim/pydantic_ai/_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from collections.abc import Sequence
from typing import Literal

from typing_extensions import assert_never

from . import exceptions, messages

try:
Expand Down Expand Up @@ -33,11 +35,16 @@ def map_from_mcp_params(params: mcp_types.CreateMessageRequestParams) -> list[me
# TODO(Marcelo): We can reuse the `_map_tool_result_part` from the mcp module here.
if isinstance(content, mcp_types.TextContent):
user_part_content: str | Sequence[messages.UserContent] = content.text
else:
# image content
elif isinstance(content, mcp_types.ImageContent):
user_part_content = [
messages.BinaryContent(data=base64.b64decode(content.data), media_type=content.mimeType)
]
elif isinstance(
content, (list, mcp_types.AudioContent, mcp_types.ToolUseContent, mcp_types.ToolResultContent)
):
raise NotImplementedError(f'Unsupported user content type: {type(content).__name__}')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the new types we could support here and below? Add a comment at lease please

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the painful stuff is that content: SamplingMessageContentBlock | list[SamplingMessageContentBlock]

but I've moved the NotImplementedError into an explicit branch checking all rest-types and added an assert_never at the end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Could we support AudioContent easily in the ImageContent branch above?
  • Let's make it clear in the error which types are not yet supported (like in map_from_sampling_content) but relatively easily could be, vs anything that is fundamentally unsupported. Just so people know what their options are when they hit this error (e.g. contribute a fix)

else:
assert_never(content)

request_parts.append(messages.UserPromptPart(content=user_part_content))
else:
Expand All @@ -47,7 +54,10 @@ def map_from_mcp_params(params: mcp_types.CreateMessageRequestParams) -> list[me
pai_messages.append(messages.ModelRequest(parts=request_parts))
request_parts = []

response_parts.append(map_from_sampling_content(content))
if isinstance(content, (mcp_types.TextContent, mcp_types.ImageContent, mcp_types.AudioContent)):
response_parts.append(map_from_sampling_content(content))
Comment thread
DouweM marked this conversation as resolved.
else:
raise NotImplementedError(f'Unsupported assistant content type: {type(content).__name__}')

if response_parts:
pai_messages.append(messages.ModelResponse(parts=response_parts))
Expand Down
132 changes: 62 additions & 70 deletions pydantic_ai_slim/pydantic_ai/mcp.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
from __future__ import annotations

import base64
import functools
import os
import re
import warnings
from abc import ABC, abstractmethod
from asyncio import Lock
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass, field, replace
from datetime import timedelta
from pathlib import Path
Expand All @@ -32,7 +31,7 @@
from mcp.client.session import ClientSession, ElicitationFnT, LoggingFnT
from mcp.client.sse import sse_client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import GetSessionIdCallback, streamablehttp_client
from mcp.client.streamable_http import streamable_http_client
from mcp.shared import exceptions as mcp_exceptions
from mcp.shared.context import RequestContext
from mcp.shared.message import SessionMessage
Expand Down Expand Up @@ -1113,67 +1112,6 @@ def __init__(
client_info=client_info,
)

@property
@abstractmethod
def _transport_client(
self,
) -> Callable[
...,
AbstractAsyncContextManager[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
GetSessionIdCallback,
],
]
| AbstractAsyncContextManager[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
]
],
]: ...

@asynccontextmanager
async def client_streams(
self,
) -> AsyncIterator[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
]
]:
if self.http_client and self.headers:
raise ValueError('`http_client` is mutually exclusive with `headers`.') # pragma: no cover

transport_client_partial = functools.partial(
self._transport_client,
url=self.url,
timeout=self.timeout,
sse_read_timeout=self.read_timeout,
)

if self.http_client is not None:
# TODO: Clean up once https://github.com/modelcontextprotocol/python-sdk/pull/1177 lands.
@asynccontextmanager
async def httpx_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> AsyncIterator[httpx.AsyncClient]:
assert self.http_client is not None
yield self.http_client

async with transport_client_partial(httpx_client_factory=httpx_client_factory) as (
read_stream,
write_stream,
*_,
):
yield read_stream, write_stream
else:
async with transport_client_partial(headers=self.headers) as (read_stream, write_stream, *_):
yield read_stream, write_stream

def __repr__(self) -> str: # pragma: no cover
repr_args = [
f'url={self.url!r}',
Expand Down Expand Up @@ -1217,9 +1155,45 @@ def __get_pydantic_core_schema__(cls, _: Any, __: Any) -> CoreSchema:
),
)

@property
def _transport_client(self):
return sse_client # pragma: no cover
# sse_client has a hang bug (https://github.com/modelcontextprotocol/python-sdk/issues/1811).
# Remove pragma once https://github.com/modelcontextprotocol/python-sdk/pull/1838 is released.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not obvious why a hang bug results in a pragma: no cover, and why we can remove the pragma once the hang bug is fixed. I think we'd need to add a new test to actually cover SSE, but your point is that we can't because of the hang bug?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

precisely

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove pragma "and add a test", right? Also worth marking as "TODO:"

@asynccontextmanager
async def client_streams( # pragma: no cover
self,
) -> AsyncIterator[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
]
]:
if self.http_client and self.headers:
raise ValueError('`http_client` is mutually exclusive with `headers`.')

if self.http_client is not None:

def httpx_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
assert self.http_client is not None
return self.http_client

async with sse_client(
url=self.url,
timeout=self.timeout,
sse_read_timeout=self.read_timeout,
httpx_client_factory=httpx_client_factory,
) as (read_stream, write_stream, *_):
yield read_stream, write_stream
else:
async with sse_client(
url=self.url,
timeout=self.timeout,
sse_read_timeout=self.read_timeout,
headers=self.headers,
) as (read_stream, write_stream, *_):
yield read_stream, write_stream

def __eq__(self, value: object, /) -> bool:
return super().__eq__(value) and isinstance(value, MCPServerSSE) and self.url == value.url
Expand Down Expand Up @@ -1281,9 +1255,27 @@ def __get_pydantic_core_schema__(cls, _: Any, __: Any) -> CoreSchema:
),
)

@property
def _transport_client(self):
return streamablehttp_client
@asynccontextmanager
async def client_streams(
self,
) -> AsyncIterator[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
]
]:
aexit_stack = AsyncExitStack()
http_client = self.http_client or await aexit_stack.enter_async_context(
httpx.AsyncClient(timeout=httpx.Timeout(self.timeout, read=self.read_timeout), headers=self.headers)
Comment thread
dsfaccini marked this conversation as resolved.
)
read_stream, write_stream, *_ = await aexit_stack.enter_async_context(
streamable_http_client(self.url, http_client=http_client)
)
try:
yield read_stream, write_stream
finally:
# unwrap the aexit
Comment thread
dsfaccini marked this conversation as resolved.
Outdated
await aexit_stack.aclose()

def __eq__(self, value: object, /) -> bool:
return super().__eq__(value) and isinstance(value, MCPServerStreamableHTTP) and self.url == value.url
Expand Down
4 changes: 2 additions & 2 deletions pydantic_ai_slim/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ cli = [
"pyperclip>=1.9.0",
]
# MCP
mcp = ["mcp>=1.18.0"]
mcp = ["mcp>=1.25.0"]
# FastMCP
fastmcp = ["fastmcp>=2.12.0"]
fastmcp = ["fastmcp>=2.14.0"]
# Evals
evals = ["pydantic-evals=={{ version }}"]
# UI
Expand Down
22 changes: 21 additions & 1 deletion tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from mcp.client.session import ClientSession
from mcp.shared.context import RequestContext
from mcp.types import (
AudioContent,
CreateMessageRequestParams,
ElicitRequestParams,
ElicitResult,
Expand Down Expand Up @@ -1659,6 +1660,20 @@ def test_map_from_mcp_params_model_response():
)


def test_map_from_mcp_params_unsupported_user_content():
params = CreateMessageRequestParams(
messages=[
SamplingMessage(
role='user',
content=AudioContent(type='audio', data='YXVkaW8=', mimeType='audio/wav'),
),
],
maxTokens=8,
)
with pytest.raises(NotImplementedError, match='Unsupported user content type: AudioContent'):
map_from_mcp_params(params)


def test_map_from_pai_messages_with_binary_content():
"""Test that map_from_pai_messages correctly converts image and audio content to MCP format.

Expand All @@ -1678,7 +1693,11 @@ def test_map_from_pai_messages_with_binary_content():
assert system_prompt == ''
assert [m.model_dump(by_alias=True) for m in sampling_msgs] == snapshot(
[
{'role': 'user', 'content': {'type': 'text', 'text': 'text message', 'annotations': None, '_meta': None}},
{
'role': 'user',
'content': {'type': 'text', 'text': 'text message', 'annotations': None, '_meta': None},
'_meta': None,
},
{
'role': 'user',
'content': {
Expand All @@ -1688,6 +1707,7 @@ def test_map_from_pai_messages_with_binary_content():
'annotations': None,
'_meta': None,
},
'_meta': None,
},
]
)
Expand Down
Loading