feat(mcp): move to mcp 2.x and pydantic-ai 2.x - #265
cardosofede wants to merge 6 commits into
Conversation
Dumped on mcp 1.26 / pydantic-ai 1.78, the last lock before the move to the 2.x majors (FEAT-130): name, description, inputSchema and outputSchema of every tool under the full profile. The parity test that reads it lands with the bump.
The two bumps are one: pydantic-ai 1.x requires mcp<2 and 2.x requires mcp>=2, so there is no resolvable intermediate state. Both now carry a <3 bound so the next major cannot arrive through uv lock --upgrade. groq is no longer in pydantic-ai's default set and is a supported prefix, hence the extra. Renames only: FastMCP -> MCPServer, OpenAIModel -> OpenAIChatModel, Tool.inputSchema -> input_schema. Both servers start and the suite collects. Red after this commit, ported in the next ones: test_token_usage (6), test_pydantic_ai_mcp_lifecycle (3), test_agent_identity (3).
Names, descriptions, input schemas and output schemas of both servers are identical to the mcp 1.26 dump; nothing had to be re-baselined.
MCP SDK 2.x reports any exception that is not its own ToolError as a bare 'Error executing tool <name>', dropping the message agents act on. Wrap every tool at the one registration seam both servers share and re-raise as the SDK's ToolError, which restores v1's 'Error executing tool <name>: <message>' for every exception type. A sync tool is refused at registration: the SDK would run it on a worker thread and the wrapper awaits what it wraps.
MCPServerStdio -> MCPToolset(StdioTransport), prepare_tools= -> the PrepareTools capability, run_mcp_servers() -> async with agent, and run.usage is a property now. Two behaviours 2.x changed underneath the client are restored where each already lived: - A stdio server outlives the agent's context by default; keep_alive=False so a stopped session leaves no subprocess behind. - The closed-stream attributes are gone and the toolset reports itself connected after a SIGKILL, so liveness is a ping. What counts is who answers: a dead transport fails it locally with CONNECTION_CLOSED, while a live mcp 2.x server answers 'Method not found' — any reply from the far side is proof of life, and a timeout means busy. The End-node text branch read an attribute pydantic-ai no longer has and was a no-op; the final text reaches the user through CallToolsNode.
… the stream is collected prompt_stream yielded from inside the request slot, agent.iter() and the usage fold. A reader that stopped mid-turn (a WS drop, a page reload, a break) left that stack to be unwound by closing the generator, which pydantic-ai's run cannot survive: aclose() raised, the slot stayed held until the half-closed generator was garbage collected, and the unwind then raised again in the finalizer where nobody could handle it. The slot is process-global per base URL, so every other session and tick on the same local backend queued behind a turn nobody was reading. The run now lives in a task the client owns (_run_turn) and prompt_stream only relays its events through a queue, in step with the reader so abort_prompt still stops the run where the reader is. When the reader closes, drops or is cancelled out of the stream, the task is cancelled and awaited: the run exits in the task that entered it, usage is folded on the way out, and the slot is released there. PromptDone no longer waits for the reader to come back, so the slot is free as soon as the turn is over. CORR-714
|
| class _NullContentSafeOpenAIChatModel(OpenAIChatModel): | ||
| async def _map_messages(self, *args: Any, **kwargs: Any) -> Any: | ||
| mapped = await super()._map_messages(*args, **kwargs) |
There was a problem hiding this comment.
Null-content behavior lacks coverage The 2.x migration changes the base class of the workaround for Ollama and LM Studio, but the new test checks only the model’s type. Please test that a tool-call-only assistant message is mapped with
content: ""; otherwise a change to the SDK’s private mapping method could go unnoticed and restore the content: null requests these backends reject.
Summary
Moves both MCP servers and the pydantic-ai agent client to the 2.x SDKs (
mcp>=2.2,<3,pydantic-ai>=2.52,<3), and fixes a turn-teardown bug in the pydantic-ai client found along the way.MCP / pydantic-ai 2.x (FEAT-130)
0b74ed96freezes the v1 tool surface of both servers as a fixture (tests/fixtures/mcp_tool_surface_v1.json).8e6489f7moves to mcp 2.x and pydantic-ai 2.x.85cc2e3dpins that the v2 tool surface matches the frozen v1 one, so the upgrade changes no tool name or schema the model sees.11bbfdd4keeps a failing tool telling the model why it failed.7b957fa8ports the pydantic-ai client to the 2.x MCP toolset.Abandoned pydantic-ai turn (CORR-714)
829b56e8:prompt_streamyielded from inside the request slot,agent.iter()and the usage fold. A reader that stopped mid-turn (a WS drop, a page reload, abreak) left that stack to be unwound by closing the generator, which pydantic-ai's run cannot survive:aclose()raised, the per-backend request slot stayed held until the generator was garbage collected, and the unwind raised again in the finalizer.prompt_streamrelays its events through a queue, in step with the reader, soabort_promptstill stops the run where the reader is. When the reader closes, drops or is cancelled out of the stream, the task is cancelled and awaited, the usage is folded and the slot is released there.PromptDoneno longer waits for the reader to come back, so the slot is free as soon as the turn is over.Known gap
The client only learns the reader left when the stream is closed, dropped, or cancelled while waiting for the next event. A reader cancelled inside its own loop body whose frame stays referenced (a kept task or traceback) still holds the slot until that frame is released. The consumers in
sessions.py,agent_run.pyandengine.pyare unchanged; closing this needsaclosingthrough the whole generator chain, ACP included.Test plan
uv run pytest -W error::pytest.PytestUnraisableExceptionWarning— 6446 passed, 2 skipped (run at829b56e8, before merging the 10 commitsmainhas gained since)blackandisortclean on the touched filestests/test_mcp_tool_schema_parity.py: v2 tool surface equals the frozen v1 onetests/test_agents.py: closing, dropping or cancelling the stream mid-turn returns the slot and leaves no turn task