Skip to content

feat: deliver MCP toolsets via a pydantic-ai capability (McpToolsets) - #834

Open
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/mcp-toolsets-capability
Open

feat: deliver MCP toolsets via a pydantic-ai capability (McpToolsets)#834
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/mcp-toolsets-capability

Conversation

@mpfaffenberger

@mpfaffenberger mpfaffenberger commented Aug 21, 2026

Copy link
Copy Markdown
Owner

What

Seventh in the capability series (#828 SteerInjection, #829 HistoryCompaction, #830 PluginMessageTransform, #831 PerModelSettings, #832 AssembledInstructions, #833 ResolvedModel): promotes MCP server delivery from the Agent(toolsets=...) constructor kwarg to the first-class get_toolset() capability seam, via a new McpToolsets capability in code_puppy/agents/_mcp_toolsets.py.

Both construction sites converted:

  • _builder.build_pydantic_agent — probe pass gets McpToolsets([]), final pass gets McpToolsets(filtered_mcp_servers)
  • subagent_invocationMcpToolsets(mcp_servers) (unfiltered, exactly as the kwarg was there)

What deliberately did NOT move

Parity (verified against pydantic-ai 2.31.0 source)

  • Lifecycle: Agent.__aenter__ enters _get_toolset(), which includes capability toolsets — MCP servers get entered/exited identically.
  • DBOS durable wrapper (the scary one): DBOSAgent reads the public agent.toolsets property (which includes capability toolsets), dbosifies leaves via visit_and_replace, and applies them per run with override(toolsets=...) — and override skips _cap_toolsets exactly as it skipped constructor _user_toolsets. Same replace semantics; pinned by tests that perform the exact traversal DBOS does.
  • Normalization: the kwarg kept AbstractToolsets (in order) and wrapped anything else in DynamicToolset(toolset_func=...) appended after; get_toolset() returns a single toolset, so the capability replicates that split-then-wrap itself, then combines (0 → None, 1 → identity, n → CombinedToolset). Memoized so repeated get_toolset() calls deliver stable identity, matching the constructor storing its list once.
  • One inert divergence: capability toolsets append after dynamic toolsets in _build_toolset_list, and ride wrapped in CapabilityOwnedToolset/CombinedToolset inside agent.toolsets. MCP servers are the only toolsets code_puppy delivers and name conflicts raise either way, so the combined run toolset is behaviorally identical; the wrapper chain is transparent to visit_and_replace (pinned by test).

Not spec-constructible (get_serialization_name() -> None): live MCP toolsets backed by subprocesses/HTTP clients owned by the MCP manager — same precedent as the other live-object capabilities in the series.

Tests

13 contract tests in tests/agents/test_mcp_toolsets_capability.py: pure normalization contract, pydantic-ai integration (public property surfacing, visit_and_replace reachability, override replacement, __aenter__ lifecycle counters, end-to-end FunctionModel tool call through a capability-delivered toolset), and real-builder integration (delivery + collision filter still applied, _user_toolsets empty, agent._mcp_servers contract pinned).

Full suite: 7589 passed; the 3 reds are the exact documented pre-existing main failures (test_render_version_check_current + the two full-run-order subagent-adjacent flakes, which pass in isolation on this branch).

Merge-order note

Like the previous six, this PR grazes the shared capabilities=[...] blocks in _builder.py + subagent_invocation.py. Whichever of the seven lands last eats trivial rebases. Musical chairs continues.

Seventh in the capability series: promotes MCP server delivery from the
Agent(toolsets=...) constructor kwarg to the first-class get_toolset()
capability seam via a new McpToolsets capability in
code_puppy/agents/_mcp_toolsets.py.

Only delivery moves. Server discovery (load_mcp_servers), bound-server
autostart, and the two-pass collision filter
(filter_conflicting_mcp_tools) are untouched and still run before the
capability is constructed; agent._mcp_servers keeps its plugin-facing
contract (agent_run_context, token_usage, acp all unaffected).

Parity verified against pydantic-ai 2.31.0 source:
- Agent.__aenter__ enters _get_toolset(), which includes capability
  toolsets, so MCP server lifecycle is identical.
- Agent.override(toolsets=...) skips _cap_toolsets exactly as it skips
  constructor _user_toolsets. This is what keeps the DBOS durable
  wrapper working: DBOSAgent reads the public agent.toolsets property
  (capability toolsets included), dbosifies leaves via
  visit_and_replace, and overrides them in per run.
- The kwarg's normalization (AbstractToolsets kept in order,
  non-toolsets wrapped in DynamicToolset and appended after) is
  replicated inside the capability, since get_toolset() returns a
  single toolset.
- One inert ordering divergence: capability toolsets append after
  dynamic toolsets in _build_toolset_list; MCP servers are the only
  toolsets code_puppy delivers, so the combined run toolset is
  identical.

Not spec-constructible (get_serialization_name -> None): live MCP
toolsets backed by subprocesses/HTTP clients owned by the MCP manager.
Review nit from PR #834 pass 1: the builder collision test now runs the
built agent against a FunctionModel and asserts the colliding MCP tool
name is hidden from the model while the surviving tool from the same
server stays visible (membership assertions, since other capabilities
like ToolOutputLimits contribute their own tools).
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