pyagentic-core 2.3.0a12__tar.gz → 2.4.0a3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. {pyagentic_core-2.3.0a12/pyagentic_core.egg-info → pyagentic_core-2.4.0a3}/PKG-INFO +5 -1
  2. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/mkdocs.yml +1 -0
  3. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/__init__.py +2 -1
  4. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_agent/_agent.py +191 -10
  5. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_info.py +13 -0
  6. pyagentic_core-2.4.0a3/pyagentic/_base/_mcp.py +264 -0
  7. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_metaclasses.py +51 -4
  8. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_spec.py +48 -1
  9. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_tool.py +25 -9
  10. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/cli/__init__.py +2 -0
  11. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/cli/_run.py +79 -7
  12. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/llm/_anthropic.py +57 -13
  13. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/models/response.py +2 -1
  14. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/serve/__init__.py +7 -5
  15. pyagentic_core-2.4.0a3/pyagentic/serve/_agent_ref.py +254 -0
  16. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/serve/_app.py +53 -10
  17. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/serve/_client_session.py +2 -22
  18. pyagentic_core-2.3.0a12/pyagentic/serve/_agent_ref.py → pyagentic_core-2.4.0a3/pyagentic/serve/_container.py +143 -305
  19. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/serve/_docker.py +5 -1
  20. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/serve/_manifest.py +33 -4
  21. pyagentic_core-2.4.0a3/pyagentic/serve/_mcp_server.py +78 -0
  22. pyagentic_core-2.4.0a3/pyagentic/serve/_sse.py +60 -0
  23. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3/pyagentic_core.egg-info}/PKG-INFO +5 -1
  24. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic_core.egg-info/SOURCES.txt +15 -0
  25. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic_core.egg-info/requires.txt +5 -0
  26. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyproject.toml +4 -1
  27. pyagentic_core-2.4.0a3/tests/cli/test_init.py +106 -0
  28. pyagentic_core-2.4.0a3/tests/serve/__init__.py +0 -0
  29. pyagentic_core-2.4.0a3/tests/serve/test_agent_ref.py +451 -0
  30. pyagentic_core-2.4.0a3/tests/serve/test_app.py +151 -0
  31. pyagentic_core-2.4.0a3/tests/serve/test_client_session.py +265 -0
  32. pyagentic_core-2.4.0a3/tests/serve/test_discovery.py +46 -0
  33. pyagentic_core-2.4.0a3/tests/serve/test_docker.py +74 -0
  34. pyagentic_core-2.4.0a3/tests/serve/test_manifest.py +136 -0
  35. pyagentic_core-2.4.0a3/tests/serve/test_metaclass_models.py +135 -0
  36. pyagentic_core-2.4.0a3/tests/serve/test_sessions.py +102 -0
  37. pyagentic_core-2.4.0a3/tests/tracing/__init__.py +0 -0
  38. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/uv.lock +752 -36
  39. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  40. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  41. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/.github/workflows/docs.yml +0 -0
  42. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/.github/workflows/release.yml +0 -0
  43. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/.github/workflows/testing.yml +0 -0
  44. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/.gitignore +0 -0
  45. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/CHANGELOG.md +0 -0
  46. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/LICENSE +0 -0
  47. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/README.md +0 -0
  48. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/agent-linking.md +0 -0
  49. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/deploy/building.md +0 -0
  50. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/deploy/creating-a-project.md +0 -0
  51. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/deploy/index.md +0 -0
  52. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/deploy/running.md +0 -0
  53. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/diagrams/declaration.svg +0 -0
  54. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/diagrams/instantiation.svg +0 -0
  55. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/diagrams/runtime.svg +0 -0
  56. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/diagrams/source/README.md +0 -0
  57. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/diagrams/source/declaration.d2 +0 -0
  58. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/diagrams/source/instantiation.d2 +0 -0
  59. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/diagrams/source/runtime.d2 +0 -0
  60. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/execution-modes.md +0 -0
  61. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/getting-started.md +0 -0
  62. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/images/langfuse.png +0 -0
  63. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/index.md +0 -0
  64. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/inheritance.md +0 -0
  65. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/observability.md +0 -0
  66. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/phases.md +0 -0
  67. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/policies.md +0 -0
  68. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/reference/architecture.md +0 -0
  69. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/reference/modules.md +0 -0
  70. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/responses.md +0 -0
  71. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/states.md +0 -0
  72. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/structured-output.md +0 -0
  73. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/docs/tools.md +0 -0
  74. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/__init__.py +0 -0
  75. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_agent/__init__.py +0 -0
  76. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_agent/_agent_linking.py +0 -0
  77. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_agent/_agent_state.py +0 -0
  78. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_exceptions.py +0 -0
  79. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_ref.py +0 -0
  80. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_state.py +0 -0
  81. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_base/_validation.py +0 -0
  82. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_utils/_typing.py +0 -0
  83. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_utils/_warnings.py +0 -0
  84. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/_version_scheme.py +0 -0
  85. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/cli/__main__.py +0 -0
  86. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/cli/_build.py +0 -0
  87. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/cli/_init.py +0 -0
  88. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/cli/_publish.py +0 -0
  89. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/cli/_templates.py +0 -0
  90. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/llm/__init__.py +0 -0
  91. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/llm/_gemini.py +0 -0
  92. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/llm/_mock.py +0 -0
  93. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/llm/_openai.py +0 -0
  94. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/llm/_openaiv1.py +0 -0
  95. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/llm/_provider.py +0 -0
  96. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/logging.py +0 -0
  97. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/models/llm.py +0 -0
  98. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/models/tracing.py +0 -0
  99. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/policies/__init__.py +0 -0
  100. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/policies/_events.py +0 -0
  101. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/policies/_policy.py +0 -0
  102. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/serve/_discovery.py +0 -0
  103. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/serve/_exceptions.py +0 -0
  104. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/serve/_sessions.py +0 -0
  105. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/tracing/__init__.py +0 -0
  106. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/tracing/_basic.py +0 -0
  107. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/tracing/_langfuse.py +0 -0
  108. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/tracing/_tracer.py +0 -0
  109. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic/updates.py +0 -0
  110. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic_core.egg-info/dependency_links.txt +0 -0
  111. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic_core.egg-info/entry_points.txt +0 -0
  112. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/pyagentic_core.egg-info/top_level.txt +0 -0
  113. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/setup.cfg +0 -0
  114. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/setup.py +0 -0
  115. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/__init__.py +0 -0
  116. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/__init__.py +0 -0
  117. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/test_agent.py +0 -0
  118. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/test_agent_inheritance.py +0 -0
  119. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/test_agent_linking.py +0 -0
  120. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/test_agent_provider.py +0 -0
  121. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/test_params.py +0 -0
  122. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/test_phases.py +0 -0
  123. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/test_state.py +0 -0
  124. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/test_tool.py +0 -0
  125. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/_base/test_validator.py +0 -0
  126. {pyagentic_core-2.3.0a12/tests/tracing → pyagentic_core-2.4.0a3/tests/cli}/__init__.py +0 -0
  127. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/conftest.py +0 -0
  128. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/models/test_responses.py +0 -0
  129. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/tracing/test_basic_tracer.py +0 -0
  130. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/tracing/test_models.py +0 -0
  131. {pyagentic_core-2.3.0a12 → pyagentic_core-2.4.0a3}/tests/tracing/test_tracer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyagentic-core
3
- Version: 2.3.0a12
3
+ Version: 2.4.0a3
4
4
  Summary: Build LLM Agents in a Pythonic way
5
5
  Author-email: Ryan Mikulec <rmikulec.dev@gmail.com>
6
6
  License: MIT
@@ -23,12 +23,16 @@ Requires-Dist: google-generativeai>=0.8.0
23
23
  Requires-Dist: transitions>=0.9.3
24
24
  Requires-Dist: ty>=0.0.15
25
25
  Requires-Dist: jinja2>=3.1.6
26
+ Requires-Dist: fastmcp>=3.4.0
27
+ Provides-Extra: mcp
28
+ Requires-Dist: fastmcp>=2.0.0; extra == "mcp"
26
29
  Provides-Extra: deploy
27
30
  Requires-Dist: typer>=0.15.0; extra == "deploy"
28
31
  Requires-Dist: fastapi>=0.115.0; extra == "deploy"
29
32
  Requires-Dist: uvicorn>=0.34.0; extra == "deploy"
30
33
  Requires-Dist: sse-starlette>=2.0.0; extra == "deploy"
31
34
  Requires-Dist: httpx>=0.28.0; extra == "deploy"
35
+ Requires-Dist: fastmcp>=2.0.0; extra == "deploy"
32
36
  Dynamic: license-file
33
37
 
34
38
  # PyAgentic
@@ -15,6 +15,7 @@ nav:
15
15
  - Agent Linking: agent-linking.md
16
16
  - Responses: responses.md
17
17
  - Structured Outputs: structured-output.md
18
+ - MCP: mcp.md
18
19
  - Inheritance: inheritance.md
19
20
  - Observability: observability.md
20
21
  - Deploy:
@@ -38,9 +38,10 @@ Main Components:
38
38
  from pyagentic._base._spec import spec
39
39
  from pyagentic._base._agent._agent import BaseAgent, AgentExtension
40
40
  from pyagentic._base._agent._agent_linking import Link
41
+ from pyagentic._base._mcp import MCPLink
41
42
  from pyagentic._base._tool import tool
42
43
 
43
44
  from pyagentic._base._state import State
44
45
  from pyagentic._base._ref import ref
45
46
 
46
- __all__ = ["BaseAgent", "AgentExtension", "tool", "spec", "State", "Link", "ref"]
47
+ __all__ = ["BaseAgent", "AgentExtension", "tool", "spec", "State", "Link", "MCPLink", "ref"]
@@ -27,6 +27,7 @@ from pyagentic._base._agent._agent_state import _AgentState
27
27
 
28
28
  if TYPE_CHECKING:
29
29
  from pyagentic._base._agent._agent_linking import _LinkedAgentDefinition
30
+ from pyagentic._base._mcp import _MCPDefinition
30
31
 
31
32
  from pyagentic.models.response import ToolResponse, AgentResponse
32
33
  from pyagentic.models.llm import Message, ToolCall, LLMResponse
@@ -161,6 +162,7 @@ class BaseAgent(metaclass=AgentMeta):
161
162
  __tool_defs__: ClassVar[dict[str, _ToolDefinition]] # Registered @tool methods
162
163
  __state_defs__: ClassVar[dict[str, _StateDefinition]] # State field definitions
163
164
  __linked_agents__: ClassVar[dict[str, "_LinkedAgentDefinition"]] # Linked agent definitions
165
+ __mcp_defs__: ClassVar[dict[str, "_MCPDefinition"]] # MCP server definitions
164
166
 
165
167
  # User-set Class Attributes (defined in subclass)
166
168
  __system_message__: ClassVar[str] # Required: system prompt for the agent
@@ -234,6 +236,126 @@ class BaseAgent(metaclass=AgentMeta):
234
236
  if self.__tool_defs__ and not self.provider.__supports_tool_calls__:
235
237
  raise Exception("Tools are not supported with this provider")
236
238
 
239
+ async def _ensure_mcp_connected(self):
240
+ """Lazily connect to all configured MCP servers and merge their tools.
241
+
242
+ Called once at the top of ``_get_tool_defs()``. On first invocation
243
+ it connects to each MCP server, fetches available tools, applies
244
+ whitelist/blacklist filtering, converts them to ``_MCPToolDefinition``
245
+ instances, and shadows the class-level ``__tool_defs__`` and
246
+ ``__tool_response_models__`` with instance-level merged dicts.
247
+
248
+ Populates ``_mcp_tool_routing`` for dispatching tool calls to the
249
+ correct MCP client.
250
+ """
251
+ if getattr(self, "_mcp_connected", False):
252
+ return
253
+
254
+ if not self.__mcp_defs__:
255
+ self._mcp_connected = True
256
+ self._mcp_clients = {}
257
+ self._mcp_tool_routing = {}
258
+ return
259
+
260
+ try:
261
+ from fastmcp import Client as MCPClient
262
+ except ImportError:
263
+ raise ImportError(
264
+ "fastmcp is required for MCP support. "
265
+ "Install it with: pip install pyagentic-core[mcp]"
266
+ )
267
+
268
+ from pyagentic._base._mcp import (
269
+ _MCPToolDefinition,
270
+ mcp_tool_to_tool_def,
271
+ _json_schema_to_parameters,
272
+ )
273
+ from pyagentic.models.response import ToolResponse
274
+
275
+ self._mcp_clients = {}
276
+ self._mcp_tool_routing = {}
277
+ mcp_tool_defs = {}
278
+ mcp_response_models = {}
279
+
280
+ for field_name, mcp_def in self.__mcp_defs__.items():
281
+ info = mcp_def.info
282
+ server = info.server
283
+
284
+ # Auto-detect transport
285
+ if isinstance(server, str) and server.startswith(("http://", "https://")):
286
+ client = MCPClient(server)
287
+ elif isinstance(server, str) and info.args is not None:
288
+ from fastmcp.client.transports import StdioTransport
289
+
290
+ transport = StdioTransport(command=server, args=info.args)
291
+ client = MCPClient(transport)
292
+ else:
293
+ # In-process FastMCP server object
294
+ client = MCPClient(server)
295
+
296
+ # Connect and fetch tools
297
+ await client.__aenter__()
298
+ self._mcp_clients[field_name] = client
299
+
300
+ tools = await client.list_tools()
301
+
302
+ # Apply whitelist/blacklist filtering
303
+ if info.tools is not None:
304
+ allowed = set(info.tools)
305
+ tools = [t for t in tools if t.name in allowed]
306
+ if info.exclude_tools is not None:
307
+ excluded = set(info.exclude_tools)
308
+ tools = [t for t in tools if t.name not in excluded]
309
+
310
+ # Convert to _MCPToolDefinition and register routing
311
+ for mcp_tool in tools:
312
+ tool_def = mcp_tool_to_tool_def(
313
+ mcp_tool, field_name, info.prefix
314
+ )
315
+ # Apply phases from MCPInfo to each tool definition
316
+ if info.phases:
317
+ tool_def.phases = info.phases
318
+ mcp_tool_defs[tool_def.name] = tool_def
319
+ self._mcp_tool_routing[tool_def.name] = (
320
+ client,
321
+ tool_def.mcp_original_name,
322
+ )
323
+
324
+ # Build a simple response model for MCP tools
325
+ params = _json_schema_to_parameters(tool_def.json_schema)
326
+ simple_def = _ToolDefinition(
327
+ name=tool_def.name,
328
+ description=tool_def.description,
329
+ parameters=params,
330
+ return_type=str,
331
+ )
332
+ mcp_response_models[tool_def.name] = ToolResponse.from_tool_def(
333
+ simple_def
334
+ )
335
+
336
+ # Shadow class-level dicts with instance-level merged versions
337
+ self.__tool_defs__ = {**self.__class__.__tool_defs__, **mcp_tool_defs}
338
+ self.__tool_response_models__ = {
339
+ **self.__class__.__tool_response_models__,
340
+ **mcp_response_models,
341
+ }
342
+ self._mcp_connected = True
343
+
344
+ async def close(self):
345
+ """Tear down all MCP client connections.
346
+
347
+ Should be called when the agent is no longer needed to clean up
348
+ any open connections or subprocesses.
349
+ """
350
+ for client in getattr(self, "_mcp_clients", {}).values():
351
+ try:
352
+ await client.__aexit__(None, None, None)
353
+ except Exception:
354
+ pass
355
+ self._mcp_clients = {}
356
+ self._mcp_tool_routing = {}
357
+ self._mcp_connected = False
358
+
237
359
  def __post_init__(self):
238
360
  """
239
361
  Post-initialization hook called after agent instance is created.
@@ -312,8 +434,9 @@ class BaseAgent(metaclass=AgentMeta):
312
434
  response_format=self.__response_format__,
313
435
  **kwargs,
314
436
  )
437
+ usage_details = response.usage.model_dump() if response.usage else {}
315
438
  self.tracer.set_attributes(
316
- usage_details=response.usage.model_dump(), model=self.provider._model
439
+ usage_details=usage_details, model=self.provider._model
317
440
  )
318
441
  return response
319
442
  except Exception as e:
@@ -393,11 +516,17 @@ class BaseAgent(metaclass=AgentMeta):
393
516
  Returns:
394
517
  ToolResponse: The response from the tool execution
395
518
  """
396
- # Add tool call message to conversation history
397
- self.state._messages.append(self.provider.to_tool_call_message(tool_call))
398
519
  self.tracer.set_attributes(**tool_call.__dict__)
399
520
  logger.info(f"Calling {tool_call.name} with kwargs: {tool_call.arguments}")
400
521
 
522
+ # Check if this is an MCP-routed tool (before appending provider-specific message)
523
+ mcp_routing = getattr(self, "_mcp_tool_routing", {})
524
+ if tool_call.name in mcp_routing:
525
+ return await self._process_mcp_tool_call(tool_call, call_depth)
526
+
527
+ # Add tool call message to conversation history
528
+ self.state._messages.append(self.provider.to_tool_call_message(tool_call))
529
+
401
530
  # Look up the tool definition and bound method
402
531
  try:
403
532
  tool_def = self.__tool_defs__[tool_call.name]
@@ -413,11 +542,11 @@ class BaseAgent(metaclass=AgentMeta):
413
542
  except ValidationError as e:
414
543
  # Handle validation errors for tool arguments
415
544
  result = f"Function Args were invalid: {str(e)}"
416
- compiled_args = {}
545
+ compiled_args = None
417
546
  self.tracer.record_exception(str(e))
418
547
  logger.exception(e)
419
548
  try:
420
- if compiled_args:
549
+ if compiled_args is not None:
421
550
  result = await _safe_run(handler, **compiled_args)
422
551
  self.tracer.set_attributes(result=result)
423
552
  except TypeError as e:
@@ -451,16 +580,65 @@ class BaseAgent(metaclass=AgentMeta):
451
580
  raw_kwargs=tool_call.arguments, call_depth=call_depth, output=result, **compiled_args
452
581
  )
453
582
 
583
+ @traced(SpanKind.TOOL)
584
+ async def _process_mcp_tool_call(self, tool_call: ToolCall, call_depth: int) -> ToolResponse:
585
+ """Processes a tool call routed to an MCP server.
586
+
587
+ Args:
588
+ tool_call (ToolCall): The tool call to execute via MCP.
589
+ call_depth (int): Current depth in the tool calling loop.
590
+
591
+ Returns:
592
+ ToolResponse: The response from the MCP tool execution.
593
+ """
594
+ # Add tool call to conversation history (provider-specific format)
595
+ self.state._messages.append(self.provider.to_tool_call_message(tool_call))
596
+
597
+ client, original_name = self._mcp_tool_routing[tool_call.name]
598
+ kwargs = json.loads(tool_call.arguments)
599
+
600
+ try:
601
+ mcp_result = await client.call_tool(original_name, kwargs)
602
+ # CallToolResult has .content (list of content blocks)
603
+ parts = []
604
+ for block in mcp_result.content:
605
+ if hasattr(block, "text"):
606
+ parts.append(block.text)
607
+ else:
608
+ parts.append(str(block))
609
+ result = "\n".join(parts)
610
+ self.tracer.set_attributes(result=result)
611
+ except Exception as e:
612
+ self.tracer.record_exception(str(e))
613
+ logger.exception(e)
614
+ result = f"MCP tool `{tool_call.name}` failed: {e}. Please kindly state to the user that it failed, provide state, and ask if they want to try again." # noqa E501
615
+
616
+ # Add result to conversation history
617
+ self.state._messages.append(
618
+ self.provider.to_tool_call_result_message(result=result, id_=tool_call.id)
619
+ )
620
+
621
+ if self.phases:
622
+ self.state._update_state_machine(phases=self.phases)
623
+
624
+ # Return a base ToolResponse (not a class-time typed subclass,
625
+ # since MCP tools are discovered at runtime)
626
+ return ToolResponse(
627
+ raw_kwargs=tool_call.arguments, call_depth=call_depth, output=result
628
+ )
629
+
454
630
  async def _get_tool_defs(self) -> list[_ToolDefinition]:
455
631
  """
456
- Builds a list of tool definitions from @tool methods and linked agents.
632
+ Builds a list of tool definitions from @tool methods, linked agents, and MCP servers.
457
633
 
458
634
  Resolves any StateRef references in tool parameters using the current agent_reference,
459
- allowing tools to dynamically reference state values.
635
+ allowing tools to dynamically reference state values. Lazily connects to MCP servers
636
+ on first call.
460
637
 
461
638
  Returns:
462
639
  list[_ToolDefinition]: List of resolved tool definitions ready for LLM
463
640
  """
641
+ await self._ensure_mcp_connected()
464
642
  tool_defs = []
465
643
 
466
644
  # Add all @tool decorated methods
@@ -540,9 +718,6 @@ class BaseAgent(metaclass=AgentMeta):
540
718
  # Add user message to conversation state
541
719
  self.state.add_user_message(input_)
542
720
 
543
- # Build tool definitions (including linked agents as tools)
544
- tool_defs = await self._get_tool_defs()
545
-
546
721
  # Track responses and prevent duplicate processing
547
722
  tool_responses: list = []
548
723
  agent_responses: list = []
@@ -553,6 +728,10 @@ class BaseAgent(metaclass=AgentMeta):
553
728
  final_ai_output: str | None = None
554
729
 
555
730
  while depth < self.max_call_depth:
731
+ # Rebuild tool definitions each iteration so phase transitions
732
+ # that occurred during tool execution are reflected
733
+ tool_defs = await self._get_tool_defs()
734
+
556
735
  # Ask the LLM what to do next (may return tool calls or final text)
557
736
  response = await self._process_llm_inference(tool_defs=tool_defs)
558
737
  yield response
@@ -610,6 +789,8 @@ class BaseAgent(metaclass=AgentMeta):
610
789
  final_ai_output = response.parsed if response.parsed else response.text
611
790
 
612
791
  # Build the structured response
792
+ if final_ai_output is None:
793
+ final_ai_output = ""
613
794
  response_fields = {
614
795
  "final_output": final_ai_output,
615
796
  "state": self.state,
@@ -83,3 +83,16 @@ class ParamInfo(_SpecInfo):
83
83
  description: MaybeRef[str] | None = None
84
84
  required: MaybeRef[bool] | None = False
85
85
  values: MaybeRef[list[str]] | None = None
86
+
87
+
88
+ @dataclass
89
+ class MCPInfo(_SpecInfo):
90
+ """Descriptor for configuring MCP server connections."""
91
+
92
+ server: Any = None
93
+ args: list[str] | None = None
94
+ tools: list[str] | None = None
95
+ exclude_tools: list[str] | None = None
96
+ prefix: bool | str = True
97
+ condition: Callable | None = None
98
+ phases: list[str] | None = None
@@ -0,0 +1,264 @@
1
+ """
2
+ MCP (Model Context Protocol) integration types and helpers.
3
+
4
+ Provides:
5
+ - ``MCPLink``: Type annotation marker for MCP server connections
6
+ - ``_MCPDefinition``: Pairs a field name with its ``MCPInfo`` config
7
+ - ``_MCPToolDefinition``: A ``_ToolDefinition`` subclass for MCP-sourced tools
8
+ - ``mcp_tool_to_tool_def()``: Converts a fastmcp ``Tool`` into ``_MCPToolDefinition``
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Self
15
+
16
+ from pyagentic._base._info import MCPInfo, ParamInfo
17
+ from pyagentic._base._tool import _ToolDefinition
18
+
19
+
20
+ class MCPLink:
21
+ """Type annotation marker for MCP server connections.
22
+
23
+ Used as a type annotation on agent class fields so the metaclass can
24
+ detect MCP server configurations. Paired with ``spec.MCPLink()`` which
25
+ returns an ``MCPInfo`` descriptor.
26
+
27
+ Example:
28
+ ```python
29
+ class MyAgent(BaseAgent):
30
+ __system_message__ = "You are helpful"
31
+
32
+ fs: MCPLink = spec.MCPLink(
33
+ "npx",
34
+ args=["@modelcontextprotocol/server-filesystem", "/tmp"],
35
+ tools=["read_file", "write_file"],
36
+ prefix=True,
37
+ )
38
+ ```
39
+ """
40
+
41
+ pass
42
+
43
+
44
+ @dataclass
45
+ class _MCPDefinition:
46
+ """Pairs an agent field name with its MCP configuration."""
47
+
48
+ field_name: str
49
+ info: MCPInfo
50
+
51
+
52
+ @dataclass
53
+ class _MCPToolDefinition(_ToolDefinition):
54
+ """A tool definition sourced from an MCP server.
55
+
56
+ Overrides the base ``_ToolDefinition`` to work with raw JSON Schema
57
+ from MCP instead of Python type introspection.
58
+
59
+ Attributes:
60
+ mcp_field_name: The agent field name (e.g. ``"fs"``).
61
+ mcp_original_name: The original tool name on the MCP server.
62
+ json_schema: Raw JSON Schema for the tool's input parameters.
63
+ """
64
+
65
+ mcp_field_name: str = ""
66
+ mcp_original_name: str = ""
67
+ json_schema: dict = field(default_factory=dict)
68
+
69
+ def __init__(
70
+ self,
71
+ *,
72
+ name: str,
73
+ description: str,
74
+ json_schema: dict,
75
+ mcp_field_name: str,
76
+ mcp_original_name: str,
77
+ ):
78
+ super().__init__(
79
+ name=name,
80
+ description=description,
81
+ parameters={},
82
+ return_type=str,
83
+ )
84
+ self.mcp_field_name = mcp_field_name
85
+ self.mcp_original_name = mcp_original_name
86
+ self.json_schema = json_schema
87
+
88
+ # JSON Schema keywords not supported by Anthropic's strict tool mode
89
+ _UNSUPPORTED_SCHEMA_KEYS = frozenset({
90
+ "$schema", "exclusiveMaximum", "exclusiveMinimum",
91
+ "maxLength", "minLength", "maxItems", "minItems",
92
+ "uniqueItems", "pattern", "format",
93
+ "maximum", "minimum", "multipleOf",
94
+ })
95
+
96
+ @classmethod
97
+ def _strip_unsupported(cls, obj: dict) -> dict:
98
+ """Recursively strip unsupported JSON Schema keys and enforce strict mode.
99
+
100
+ Also injects ``additionalProperties: false`` on every ``object``
101
+ type node, as required by Anthropic's strict tool mode.
102
+ """
103
+ cleaned = {}
104
+ for key, value in obj.items():
105
+ if key in cls._UNSUPPORTED_SCHEMA_KEYS:
106
+ continue
107
+ if isinstance(value, dict):
108
+ cleaned[key] = cls._strip_unsupported(value)
109
+ else:
110
+ cleaned[key] = value
111
+ # Anthropic strict mode requires additionalProperties: false
112
+ # on every object-typed node in the schema
113
+ if cleaned.get("type") == "object":
114
+ cleaned["additionalProperties"] = False
115
+ return cleaned
116
+
117
+ def _clean_schema(self) -> dict:
118
+ """Return a cleaned copy of the MCP JSON Schema.
119
+
120
+ Strips meta-keys and unsupported validation keywords that LLM
121
+ APIs don't accept, and ensures ``type`` and ``properties`` are
122
+ present.
123
+ """
124
+ schema = self._strip_unsupported(
125
+ dict(self.json_schema) if self.json_schema else {}
126
+ )
127
+ if "type" not in schema:
128
+ schema["type"] = "object"
129
+ if "properties" not in schema:
130
+ schema["properties"] = {}
131
+ return schema
132
+
133
+ def to_openai_spec(self) -> dict:
134
+ """Emit the raw JSON Schema from the MCP server.
135
+
136
+ Returns:
137
+ dict: An OpenAI-compliant tool specification dictionary.
138
+ """
139
+ return {
140
+ "type": "function",
141
+ "name": self.name,
142
+ "description": self.description,
143
+ "parameters": self._clean_schema(),
144
+ }
145
+
146
+ def to_anthropic_spec(self) -> dict:
147
+ """Emit Anthropic-formatted tool spec with strict mode from MCP JSON Schema.
148
+
149
+ Uses ``strict: true`` and ``additionalProperties: false`` to guarantee
150
+ schema conformance via grammar-constrained sampling.
151
+
152
+ Returns:
153
+ dict: An Anthropic-compliant tool specification dictionary.
154
+ """
155
+ schema = self._clean_schema()
156
+ schema["additionalProperties"] = False
157
+
158
+ return {
159
+ "name": self.name,
160
+ "description": self.description,
161
+ "strict": True,
162
+ "input_schema": schema,
163
+ }
164
+
165
+ def compile_args(self, **kwargs) -> dict[str, Any]:
166
+ """Pass-through: MCP handles its own validation.
167
+
168
+ Args:
169
+ **kwargs: Raw keyword arguments from the LLM tool call.
170
+
171
+ Returns:
172
+ dict[str, Any]: The same kwargs, unmodified.
173
+ """
174
+ return kwargs
175
+
176
+ def resolve(self, agent_reference: dict) -> Self:
177
+ """MCP tools have no StateRefs to resolve.
178
+
179
+ Args:
180
+ agent_reference (dict): The agent reference dict (unused).
181
+
182
+ Returns:
183
+ Self: Returns self unchanged.
184
+ """
185
+ return self
186
+
187
+
188
+ def _json_schema_to_parameters(
189
+ schema: dict,
190
+ ) -> dict[str, tuple[type, ParamInfo]]:
191
+ """Convert a JSON Schema properties dict to ``(type, ParamInfo)`` pairs.
192
+
193
+ This is a simple mapping used for response model compatibility. Complex
194
+ schemas fall back to ``str``.
195
+
196
+ Args:
197
+ schema (dict): JSON Schema with ``properties`` and optionally ``required``.
198
+
199
+ Returns:
200
+ dict[str, tuple[type, ParamInfo]]: Mapping of parameter names to
201
+ ``(python_type, ParamInfo)`` tuples.
202
+ """
203
+ _JSON_TYPE_MAP = {
204
+ "string": str,
205
+ "integer": int,
206
+ "number": float,
207
+ "boolean": bool,
208
+ }
209
+ properties = schema.get("properties", {})
210
+ required_fields = set(schema.get("required", []))
211
+ params: dict[str, tuple[type, ParamInfo]] = {}
212
+
213
+ for prop_name, prop_schema in properties.items():
214
+ json_type = prop_schema.get("type", "string")
215
+ py_type = _JSON_TYPE_MAP.get(json_type, str)
216
+ is_required = prop_name in required_fields
217
+ description = prop_schema.get("description")
218
+ params[prop_name] = (
219
+ py_type,
220
+ ParamInfo(required=is_required, description=description),
221
+ )
222
+
223
+ return params
224
+
225
+
226
+ def mcp_tool_to_tool_def(
227
+ mcp_tool: Any,
228
+ field_name: str,
229
+ prefix: bool | str,
230
+ ) -> _MCPToolDefinition:
231
+ """Convert a fastmcp ``Tool`` object to an ``_MCPToolDefinition``.
232
+
233
+ Applies prefix logic: when *prefix* is ``True``, the tool name becomes
234
+ ``{field_name}__{original_name}``. When *prefix* is a string, it is
235
+ used instead of *field_name*.
236
+
237
+ Args:
238
+ mcp_tool (Any): A fastmcp ``Tool`` object with ``name``,
239
+ ``description``, and ``inputSchema`` attributes.
240
+ field_name (str): The agent field name (e.g. ``"fs"``).
241
+ prefix (bool | str): Prefix mode — ``True`` uses *field_name*,
242
+ a string uses that value, ``False`` uses no prefix.
243
+
244
+ Returns:
245
+ _MCPToolDefinition: The converted tool definition.
246
+ """
247
+ original_name = mcp_tool.name
248
+ description = mcp_tool.description or ""
249
+ input_schema = mcp_tool.inputSchema if hasattr(mcp_tool, "inputSchema") else {}
250
+
251
+ if prefix is True:
252
+ prefixed_name = f"{field_name}__{original_name}"
253
+ elif isinstance(prefix, str) and prefix:
254
+ prefixed_name = f"{prefix}__{original_name}"
255
+ else:
256
+ prefixed_name = original_name
257
+
258
+ return _MCPToolDefinition(
259
+ name=prefixed_name,
260
+ description=description,
261
+ json_schema=input_schema,
262
+ mcp_field_name=field_name,
263
+ mcp_original_name=original_name,
264
+ )