Skip to content

feat(mcp): move to mcp 2.x and pydantic-ai 2.x - #265

Open
cardosofede wants to merge 6 commits into
mainfrom
feat/mcp-sdk-v2
Open

cardosofede wants to merge 6 commits into
mainfrom
feat/mcp-sdk-v2

Conversation

@cardosofede

Copy link
Copy Markdown
Contributor

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)

  • 0b74ed96 freezes the v1 tool surface of both servers as a fixture (tests/fixtures/mcp_tool_surface_v1.json).
  • 8e6489f7 moves to mcp 2.x and pydantic-ai 2.x.
  • 85cc2e3d pins that the v2 tool surface matches the frozen v1 one, so the upgrade changes no tool name or schema the model sees.
  • 11bbfdd4 keeps a failing tool telling the model why it failed.
  • 7b957fa8 ports the pydantic-ai client to the 2.x MCP toolset.

Abandoned pydantic-ai turn (CORR-714)

  • 829b56e8: 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 per-backend request slot stayed held until the generator was garbage collected, and the unwind raised again in the finalizer.
  • The run now lives in a task the client owns and prompt_stream 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 usage is folded 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.

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.py and engine.py are unchanged; closing this needs aclosing through the whole generator chain, ACP included.

Test plan

  • uv run pytest -W error::pytest.PytestUnraisableExceptionWarning — 6446 passed, 2 skipped (run at 829b56e8, before merging the 10 commits main has gained since)
  • black and isort clean on the touched files
  • tests/test_mcp_tool_schema_parity.py: v2 tool surface equals the frozen v1 one
  • tests/test_agents.py: closing, dropping or cancelling the stream mid-turn returns the slot and leaves no turn task
  • Live chat and a loop tick on a local backend (LM Studio / Ollama) against the 2.x SDKs

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
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[High risk] Upgrades MCP and pydantic-ai to major versions 2.x.

The PR appears safe to merge; the remaining feedback is a non-blocking regression-test gap.

Findings

  1. P2 Null-content behavior lacks coverage ▶

Summary

The PR migrates both MCP servers and the pydantic-ai client to their 2.x SDKs and moves streamed turns into client-owned tasks for orderly cancellation.

  • It freezes and checks the advertised MCP tool surface, preserves tool-error messages, and updates provider and lifecycle tests.
  • The null-content compatibility override would benefit from a behavioral regression test.
Diagram
sequenceDiagram
  participant Reader
  participant Stream as prompt_stream
  participant Turn as Owned turn task
  participant Agent as pydantic-ai run
  Reader->>Stream: Request next event
  Stream->>Turn: Start turn
  Turn->>Agent: Iterate run
  Turn-->>Stream: Queue event
  Stream-->>Reader: Yield event
  Reader->>Stream: Resume or close
  alt Resume
    Stream-->>Turn: Acknowledge event
  else Close
    Stream->>Turn: Cancel and await
    Turn->>Agent: Unwind run and release slot
  end
Loading

Reviews (1) · Last reviewed commit: "fix(agents): a pydantic-ai turn ends whe..."

Comment on lines +216 to 218
class _NullContentSafeOpenAIChatModel(OpenAIChatModel):
async def _map_messages(self, *args: Any, **kwargs: Any) -> Any:
mapped = await super()._map_messages(*args, **kwargs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant