fastmcp/tests/downstream/smoke_pydantic_ai.py
nate nowack 3ca425416e
ci: add langchain.mcp and a FastMCP proxy to the downstream smoke (#5222)
langchain 1.4.2 ships `langchain.mcp`, whose MCPAdapter wraps fastmcp.Client
and ClientGroup directly (resolve_tool, list_tools cache_mode, call_tool,
Client.new, elicitation callbacks, MCPConfig, legacy and auto modes). The
workflow now runs langchain's own MCP tests against the built wheels, and a
new smoke drives MCPAdapter in-process, over stdio, MCPConfig, HTTP, SSE, a
proxy, and a ClientGroup of a legacy HTTP server and a modern stdio server.

Every smoke also runs through a FastMCP proxy in front of a stdio server and
covers shapes this release cycle got wrong: `|` and non-ASCII template
literals, comma-joined and exploded list query params, a float schema count,
and a timed-out call followed by another on the same connection. Gaps the
proxy already had in 4.0.5 are reported as known gaps that fail the run once
they start passing. Server stderr goes to a log file, and the SDK's SSE
reader is quieted where a check cancels a call on purpose.

langchain-mcp-adapters' smoke is renamed smoke_langchain_mcp_adapters.py.


Claude-Session: https://claude.ai/code/session_01KfHgVhbYEhBCC5eSeqGiuG

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 16:48:28 -05:00

304 lines
10 KiB
Python

"""Drive FastMCP through pydantic-ai's MCPToolset, as a pydantic-ai user installs it.
`pydantic-ai-slim[mcp]` depends on the client-only `fastmcp-slim[client]`, so
this runs in its own environment with no FastMCP server dependencies, resolved
together with the build under test. The servers run from the checkout:
uv build --wheel --out-dir dist fastmcp_slim
uv run --isolated --no-project --with 'pydantic-ai-slim[mcp]' \
--with "fastmcp-slim[client] @ file://$PWD/$(ls dist/fastmcp_slim-*.whl)" \
python tests/downstream/smoke_pydantic_ai.py
"""
import asyncio
import json
import os
import tempfile
from importlib.metadata import version
from pathlib import Path
from typing import Any
import anyio
from _harness import (
INSTRUCTIONS,
PROXY_GAPS,
TEMPLATE_READS,
TOOLS,
Report,
quiet,
serve,
server_env,
stdio_command,
stdio_transport,
)
from pydantic_ai import Agent, BinaryContent, ModelRetry
from pydantic_ai.mcp import MCPToolset, load_mcp_toolsets
from pydantic_ai.messages import (
ModelMessage,
ModelResponse,
TextPart,
ToolCallPart,
ToolReturnPart,
)
from pydantic_ai.models.function import AgentInfo, FunctionModel
import fastmcp
def scripted_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
"""Call add and forecast on the first turn, then answer with what they returned."""
returns = [
p
for m in messages
for p in getattr(m, "parts", [])
if isinstance(p, ToolReturnPart)
]
if not returns:
# load_mcp_toolsets prefixes tool names with the server's config key.
name = {t.name.rsplit("_", 1)[-1]: t.name for t in info.function_tools}
name.update({t.name: t.name for t in info.function_tools})
return ModelResponse(
parts=[
ToolCallPart(name["add"], {"a": 2, "b": 3}),
ToolCallPart(name["forecast"], {"city": "Chicago", "days": 2}),
]
)
return ModelResponse(parts=[TextPart(" | ".join(str(p.content) for p in returns))])
async def accept(
message: str, response_type: Any, params: Any, context: Any
) -> dict[str, bool]:
return {"approved": True}
async def exercise(
report: Report,
target: Any,
headers: dict[str, str] | None = None,
gaps: dict[str, str] | None = None,
) -> None:
progress: list[float] = []
logs: list[str] = []
async def on_progress(
value: float, total: float | None, message: str | None
) -> None:
progress.append(value)
async def on_log(message: Any) -> None:
logs.append(str(message.data))
toolset = MCPToolset(
target,
headers=headers,
include_instructions=True,
elicitation_handler=accept,
progress_handler=on_progress,
log_handler=on_log,
)
async with toolset:
async def lists_tools() -> None:
names = {tool.name for tool in await toolset.list_tools()}
assert TOOLS <= names, names
async def instructions() -> None:
assert toolset.instructions == INSTRUCTIONS, toolset.instructions
async def agent_loop() -> None:
agent = Agent(FunctionModel(scripted_model), toolsets=[toolset])
result = await agent.run("add 2 and 3, then get the Chicago forecast")
assert "5" in result.output and "sunny" in result.output, result.output
async def structured_output() -> None:
forecast = await toolset.direct_call_tool("forecast", {"city": "Oslo"})
assert forecast == {
"city": "Oslo",
"celsius": 21.5,
"conditions": ["sunny"],
}, forecast
async def tool_error() -> None:
try:
await toolset.direct_call_tool("divide", {"a": 1, "b": 0})
except ModelRetry as error:
assert "divide by zero" in str(error), error
else:
raise AssertionError("divide by zero did not raise ModelRetry")
async def image() -> None:
result = await toolset.direct_call_tool("snapshot", {})
assert (
isinstance(result, BinaryContent) and result.media_type == "image/png"
), result
async def audio() -> None:
result = await toolset.direct_call_tool("chime", {})
assert (
isinstance(result, BinaryContent) and result.media_type == "audio/wav"
), result
async def mixed_content() -> None:
result = await toolset.direct_call_tool("attachments", {})
assert result == ["see attached", "buy milk", "config://app"], result
async def elicitation() -> None:
result = await toolset.direct_call_tool("confirm", {"action": "deploy"})
assert result == "deploy: approved", result
async def progress_notifications() -> None:
progress.clear()
assert (
await toolset.direct_call_tool("count_to", {"n": 3}) == "counted to 3"
)
assert progress == [1, 2, 3], progress
async def log_messages() -> None:
logs.clear()
await toolset.direct_call_tool("count_to", {"n": 1})
assert any("counted to 1" in line for line in logs), logs
async def resources() -> None:
assert "config://app" in {
str(r.uri) for r in await toolset.list_resources()
}
assert await toolset.read_resource("config://app") == '{"mode": "smoke"}'
async def resource_template() -> None:
templates = {
t.uri_template for t in await toolset.list_resource_templates()
}
assert "greeting://{name}" in templates, templates
assert await toolset.read_resource("greeting://nate") == "hello, nate"
async def prompt() -> None:
result = await toolset.get_prompt("review", {"code": "x = 1"})
assert "x = 1" in str(result.messages[0].content), result
async def template_literals_and_list_queries() -> None:
for uri, expected in TEMPLATE_READS.items():
assert await toolset.read_resource(uri) == expected, uri
async def float_count_output_schema() -> None:
result = await toolset.direct_call_tool("stamp", {})
assert result == {"when": "2026-09-22T00:00:00Z"}, result
async def timed_out_call_leaves_connection_usable() -> None:
with quiet("mcp.client.sse"):
try:
with anyio.fail_after(0.5):
await toolset.direct_call_tool("sleep", {"seconds": 5})
except TimeoutError:
pass
else:
raise AssertionError("sleep(5) finished inside a 0.5s deadline")
with anyio.fail_after(10):
assert await toolset.direct_call_tool("add", {"a": 1, "b": 1}) == 2
await anyio.sleep(0.2)
for check in (
lists_tools,
instructions,
agent_loop,
structured_output,
tool_error,
image,
audio,
mixed_content,
elicitation,
progress_notifications,
resources,
resource_template,
prompt,
template_literals_and_list_queries,
float_count_output_schema,
timed_out_call_leaves_connection_usable,
):
name = check.__name__.replace("_", " ")
await report.check(name, check, known_gap=(gaps or {}).get(name))
await report.check(
"logging", log_messages, known_gap=(gaps or {}).get("logging")
)
async def main() -> None:
report = Report(
"pydantic-ai",
{
"fastmcp": fastmcp.__version__,
"pydantic-ai-slim": version("pydantic-ai-slim"),
"mcp": version("mcp"),
},
)
async def installed_build_is_under_test() -> None:
expected = os.environ.get("FASTMCP_EXPECTED_VERSION")
assert expected in (None, fastmcp.__version__), (
f"expected {expected}, got {fastmcp.__version__}"
)
command, args = stdio_command()
with report.transport("stdio"):
await report.check(
"installed build is under test", installed_build_is_under_test
)
await exercise(report, stdio_transport())
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "mcp.json"
config.write_text(
json.dumps(
{
"mcpServers": {
"smoke": {"command": command, "args": args, "env": server_env()}
}
}
)
)
with report.transport("mcp.json config"):
async def agent_loop_from_config() -> None:
agent = Agent(
FunctionModel(scripted_model), toolsets=load_mcp_toolsets(config)
)
result = await agent.run("add 2 and 3, then get the Chicago forecast")
assert "5" in result.output and "sunny" in result.output, result.output
await report.check(
"agent loop from load_mcp_toolsets", agent_loop_from_config
)
for transport, label in (
("http", "streamable HTTP"),
("sse", "SSE"),
("proxy", "FastMCP proxy"),
):
# The SDK's SSE reader logs a traceback when a check cancels a call on purpose.
with (
report.transport(label),
serve(transport) as server,
quiet("mcp.client.sse"),
):
async def rejects_missing_token() -> None:
try:
async with MCPToolset(server.url):
pass
except Exception:
return
raise AssertionError("connected without a bearer token")
await report.check("rejects missing bearer token", rejects_missing_token)
await exercise(
report,
server.url,
headers=server.headers,
gaps=PROXY_GAPS if transport == "proxy" else None,
)
report.finish()
if __name__ == "__main__":
asyncio.run(main())