Skip to content

Commit 7900a86

Browse files
committed
Simplify Agents GitHub and Code agents, bring pydantic bridge into repo
1 parent b1b206b commit 7900a86

File tree

71 files changed

+1664
-3197
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+1664
-3197
lines changed

.github/workflows/publish-to-pypi.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ jobs:
2020
- id: set-matrix
2121
run: echo "projects=$(find . -name "pyproject.toml" -not -path "*/.venv/*" -exec dirname {} \; | jq -R -s -c 'split("\n")[:-1]')" >> $GITHUB_OUTPUT
2222

23-
test:
24-
name: Run Tests
23+
publish:
2524
continue-on-error: true
2625
runs-on: ubuntu-latest
2726
needs: [list-projects]
@@ -32,6 +31,9 @@ jobs:
3231
fail-fast: false
3332
matrix:
3433
pyproject: ${{fromJson(needs.list-projects.outputs.projects)}}
34+
35+
name: ${{ matrix.pyproject }} - Publish to PyPI
36+
3537
steps:
3638
- name: Checkout
3739
uses: actions/checkout@v4
@@ -54,6 +56,7 @@ jobs:
5456
working-directory: ${{ matrix.pyproject }}
5557
env:
5658
GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
59+
GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5760
MODEL: ${{ vars.MODEL }}
5861

5962
- name: Clean
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
55
"version": "0.2.0",
66
"configurations": [
7+
{
8+
"name": "Python Debugger: Current File",
9+
"type": "debugpy",
10+
"request": "launch",
11+
"program": "${file}",
12+
"console": "integratedTerminal"
13+
},
714
// Debug Tests config
815
{
916
"name": "Python: Debug Tests",
@@ -20,13 +27,5 @@
2027
"console": "integratedTerminal",
2128
"justMyCode": false
2229
},
23-
24-
{
25-
"name": "Python Debugger: Current File",
26-
"type": "debugpy",
27-
"request": "launch",
28-
"program": "${file}",
29-
"console": "integratedTerminal"
30-
}
3130
]
3231
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
],
55
"python.testing.unittestEnabled": false,
66
"python.testing.pytestEnabled": true,
7-
"python-envs.pythonProjects": []
7+
"python.venvPath": "/Users/bill.easton/repos/fastmcp-ai-agent-bridge"
88
}
File renamed without changes.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from typing import TYPE_CHECKING, Any
2+
3+
import logfire
4+
from fastmcp import FastMCP
5+
from fastmcp.mcp_config import MCPConfig, TransformingStdioMCPServer
6+
from pydantic import BaseModel
7+
from pydantic_ai import Agent
8+
from pydantic_ai.models.google import GoogleModel
9+
from pydantic_ai.providers.google import GoogleProvider
10+
from pydantic_ai.settings import ModelSettings
11+
12+
from fastmcp_agents.bridge.pydantic_ai.toolset import FastMCPToolset
13+
14+
if TYPE_CHECKING:
15+
from pydantic_ai.agent import AgentRunResult
16+
17+
Agent.instrument_all()
18+
19+
logfire_config = logfire.configure(console=logfire.ConsoleOptions(min_log_level="info"), send_to_logfire=False)
20+
21+
model = GoogleModel("gemini-2.5-flash", provider=GoogleProvider(vertexai=True), settings=ModelSettings())
22+
23+
mcp_config = MCPConfig(
24+
mcpServers={
25+
"echo": TransformingStdioMCPServer(
26+
command="uvx",
27+
args=["mcp-server-time"],
28+
tools={},
29+
),
30+
},
31+
)
32+
33+
fastmcp_toolset: FastMCPToolset[None] = FastMCPToolset[None].from_mcp_config(mcp_config)
34+
35+
agent = Agent(
36+
"model",
37+
system_prompt="Be concise, reply with one sentence.",
38+
toolsets=[fastmcp_toolset],
39+
)
40+
41+
42+
class ConvertTimezonesResponse(BaseModel):
43+
provided_time: str
44+
converted_time: str
45+
starting_timezone: str
46+
ending_timezone: str
47+
48+
49+
proxy: FastMCP[Any] = FastMCP[Any](name="time_zone")
50+
51+
52+
@proxy.tool(name="convert_timezones")
53+
async def convert_timezones(time: str, from_timezone: str, to_timezone: str) -> ConvertTimezonesResponse:
54+
"""Convert a time from one timezone to another"""
55+
56+
result: AgentRunResult[ConvertTimezonesResponse] = await agent.run(
57+
f"Convert {time} from {from_timezone} to {to_timezone}", output_type=ConvertTimezonesResponse
58+
)
59+
60+
return result.output
61+
62+
63+
if __name__ == "__main__":
64+
proxy.run(transport="sse")
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
[project]
2+
name = "fastmcp-agents-bridge-pydantic-ai"
3+
version = "0.1.4"
4+
description = "Pydantic AI Agent Bridge"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"fastmcp",
9+
"logfire>=3.25.0",
10+
"pydantic-ai>=0.4.4",
11+
]
12+
13+
[dependency-groups]
14+
dev = [
15+
"pytest",
16+
"pytest-mock",
17+
"pytest-asyncio",
18+
"ruff",
19+
"basedpyright>=1.30.1",
20+
]
21+
22+
lint = [
23+
"ruff"
24+
]
25+
26+
[build-system]
27+
requires = ["uv_build>=0.8.2,<0.9.0"]
28+
build-backend = "uv_build"
29+
30+
[tool.uv.build-backend]
31+
module-name = "fastmcp_agents.bridge.pydantic_ai"
32+
33+
[tool.uv.sources]
34+
fastmcp = { git = "https://github.com/jlowin/fastmcp" }
35+
36+
[tool.ruff]
37+
extend="../../pyproject.toml"
38+
39+
[tool.pyright]
40+
extends = "../../pyproject.toml"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .toolset import FastMCPToolset
2+
3+
__all__ = ["FastMCPToolset"]
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import json
22
from datetime import UTC, datetime
3+
from typing import Any
34

45
import logfire
56
from logfire import ConsoleOptions
67
from opentelemetry.sdk.trace import ReadableSpan
78
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
9+
810
from pydantic_ai import Agent
911

1012

@@ -18,7 +20,7 @@ def get_tool_names_from_span(span: ReadableSpan) -> list[str]:
1820
if not isinstance(model_request_parameters, str):
1921
return []
2022

21-
deserialized_model_request_parameters = json.loads(model_request_parameters)
23+
deserialized_model_request_parameters: dict[str, Any] = json.loads(model_request_parameters)
2224

2325
if not (function_tools := deserialized_model_request_parameters.get("function_tools")):
2426
return []
@@ -64,6 +66,7 @@ def get_picked_tools_from_span(span: ReadableSpan) -> list[str]:
6466

6567
def format_span(span: ReadableSpan) -> str:
6668
timestamp: datetime | None = datetime.fromtimestamp(span.start_time / 1_000_000_000, tz=UTC) if span.start_time else None
69+
model_name: str | None
6770

6871
span_message = span.name
6972

@@ -78,15 +81,15 @@ def format_span(span: ReadableSpan) -> str:
7881

7982
match span.name:
8083
case "running tool":
81-
model_name: str | None = str(span.attributes.get("gen_ai.request.model"))
84+
model_name = str(span.attributes.get("gen_ai.request.model"))
8285
tool_name: str | None = str(span.attributes.get("gen_ai.tool.name"))
8386
tool_arguments: str | None = str(span.attributes.get("tool_arguments"))
8487
tool_response: str | None = str(span.attributes.get("tool_response"))
8588

8689
span_message = f"Model called {tool_name} with arguments: {tool_arguments} returned: {tool_response[:200]}"
8790

8891
case _ if span.name.startswith("chat "):
89-
model_name: str | None = str(span.attributes.get("gen_ai.request.model"))
92+
model_name = str(span.attributes.get("gen_ai.request.model"))
9093
# tool_names: list[str] = get_tool_names_from_span(span)
9194
picked_tools: list[str] = get_picked_tools_from_span(span)
9295
span_message = f"Model: {model_name} -- Picked tools: {picked_tools}"
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import Any, Self
2+
3+
from fastmcp.tools import FunctionTool
4+
from pydantic import BaseModel
5+
from pydantic.type_adapter import TypeAdapter
6+
7+
from pydantic_ai.agent import Agent, AgentRunResult
8+
9+
10+
class AgentTool[InputModel: BaseModel, OutputModel: BaseModel](FunctionTool):
11+
@classmethod
12+
def from_agent(cls, agent: Agent[Any, OutputModel], input_model: type[InputModel], name: str, description: str, prompt: str) -> Self:
13+
async def invoke_agent(input_model: InputModel) -> OutputModel:
14+
task = prompt.format(input_model=input_model, **input_model.model_dump())
15+
16+
result: AgentRunResult[OutputModel] = await agent.run(task)
17+
return result.output
18+
19+
return cls(
20+
name=name,
21+
fn=invoke_agent,
22+
description=description,
23+
parameters=input_model.model_json_schema(),
24+
output_schema=TypeAdapter(agent.output_type).json_schema(),
25+
)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import contextlib
5+
from typing import TYPE_CHECKING, Any, ClassVar, override
6+
7+
import pydantic_core
8+
from fastmcp import FastMCP # noqa: TC002
9+
from fastmcp.exceptions import ToolError
10+
from fastmcp.mcp_config import MCPConfig
11+
from fastmcp.utilities.mcp_config import composite_server_from_mcp_config # pyright: ignore[reportUnknownVariableType]
12+
from mcp.types import AudioContent, ContentBlock, EmbeddedResource, ImageContent, TextContent, TextResourceContents
13+
from pydantic import BaseModel, ConfigDict, Field
14+
15+
from pydantic_ai.exceptions import ModelRetry
16+
from pydantic_ai.mcp import TOOL_SCHEMA_VALIDATOR, messages
17+
from pydantic_ai.tools import AgentDepsT, ToolDefinition
18+
from pydantic_ai.toolsets import AbstractToolset
19+
from pydantic_ai.toolsets.abstract import ToolsetTool
20+
21+
if TYPE_CHECKING:
22+
from fastmcp.mcp_config import MCPServerTypes
23+
from fastmcp.tools import Tool as FastMCPTool
24+
from fastmcp.tools.tool import ToolResult
25+
from fastmcp.tools.tool_transform import ToolTransformConfig
26+
27+
from pydantic_ai.tools import RunContext
28+
29+
30+
FastMCPToolResult = messages.BinaryContent | dict[str, Any] | str | None
31+
32+
FastMCPToolResults = list[FastMCPToolResult] | FastMCPToolResult
33+
34+
35+
class FastMCPToolset(BaseModel, AbstractToolset[AgentDepsT]): # pyright: ignore[reportUnsafeMultipleInheritance]
36+
model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True)
37+
fastmcp: FastMCP[Any] = Field(..., description="The fastmcp instance to bridge to")
38+
tool_retries: int = Field(default=2, description="The number of times to retry a failed tool call")
39+
40+
@override
41+
async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]:
42+
fastmcp_tools: dict[str, FastMCPTool] = await self.fastmcp.get_tools()
43+
44+
return {
45+
tool.name: convert_fastmcp_tool_to_toolset_tool(toolset=self, fastmcp_tool=tool, retries=self.tool_retries)
46+
for tool in fastmcp_tools.values()
47+
}
48+
49+
async def get_tool(self, key: str, transformation: ToolTransformConfig | None = None) -> ToolsetTool[AgentDepsT]:
50+
fastmcp_tool: FastMCPTool = await self.fastmcp.get_tool(key=key)
51+
52+
if transformation:
53+
fastmcp_tool = transformation.apply(fastmcp_tool)
54+
55+
return convert_fastmcp_tool_to_toolset_tool(toolset=self, fastmcp_tool=fastmcp_tool, retries=self.tool_retries)
56+
57+
def add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None:
58+
self.fastmcp.add_tool_transformation(tool_name=tool_name, transformation=transformation)
59+
60+
def remove_tool_transformation(self, tool_name: str) -> None:
61+
self.fastmcp.remove_tool_transformation(tool_name=tool_name)
62+
63+
@override
64+
async def call_tool(self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT]) -> Any: # pyright: ignore[reportAny]
65+
fastmcp_tool: FastMCPTool = await self.fastmcp.get_tool(key=name)
66+
try:
67+
call_tool_result: ToolResult = await fastmcp_tool.run(arguments=tool_args)
68+
except ToolError as e:
69+
raise ModelRetry(message=str(object=e)) from e
70+
return call_tool_result.structured_content or _map_fastmcp_tool_results(parts=call_tool_result.content)
71+
72+
async def call_tool_direct(self, name: str, tool_args: dict[str, Any]) -> Any: # pyright: ignore[reportAny]
73+
fastmcp_tool: FastMCPTool = await self.fastmcp.get_tool(key=name)
74+
try:
75+
call_tool_result: ToolResult = await fastmcp_tool.run(arguments=tool_args)
76+
except ToolError as e:
77+
raise ModelRetry(message=str(object=e)) from e
78+
return call_tool_result.structured_content or _map_fastmcp_tool_results(parts=call_tool_result.content)
79+
80+
@classmethod
81+
def from_mcp_config(cls, mcp_config: MCPConfig | dict[str, MCPServerTypes] | dict[str, Any]) -> FastMCPToolset[AgentDepsT]:
82+
if not isinstance(mcp_config, MCPConfig):
83+
mcp_config = MCPConfig(mcpServers=mcp_config)
84+
85+
mcp_server_host: FastMCP[Any] = composite_server_from_mcp_config(config=mcp_config, name_as_prefix=False)
86+
87+
return cls(fastmcp=mcp_server_host)
88+
89+
@classmethod
90+
def from_mcp_server(cls, name: str, mcp_server: MCPServerTypes) -> FastMCPToolset[AgentDepsT]:
91+
mcp_config = MCPConfig(mcpServers={name: mcp_server})
92+
return cls.from_mcp_config(mcp_config=mcp_config)
93+
94+
95+
def convert_fastmcp_tool_to_toolset_tool(
96+
toolset: FastMCPToolset[AgentDepsT], fastmcp_tool: FastMCPTool, retries: int
97+
) -> ToolsetTool[AgentDepsT]:
98+
return ToolsetTool[AgentDepsT](
99+
tool_def=ToolDefinition(
100+
name=fastmcp_tool.name,
101+
description=fastmcp_tool.description,
102+
parameters_json_schema=fastmcp_tool.parameters,
103+
),
104+
toolset=toolset,
105+
max_retries=retries,
106+
args_validator=TOOL_SCHEMA_VALIDATOR,
107+
)
108+
109+
110+
def _map_fastmcp_tool_results(parts: list[ContentBlock]) -> list[FastMCPToolResult]:
111+
return [_map_fastmcp_tool_result(part) for part in parts]
112+
113+
114+
def _map_fastmcp_tool_result(part: ContentBlock) -> FastMCPToolResult:
115+
if isinstance(part, TextContent):
116+
text = part.text
117+
if text.startswith(("[", "{")):
118+
with contextlib.suppress(ValueError):
119+
result: Any = pydantic_core.from_json(text) # pyright: ignore[reportAny]
120+
if isinstance(result, dict | list):
121+
return result # pyright: ignore[reportUnknownVariableType, reportReturnType]
122+
return text
123+
124+
if isinstance(part, ImageContent):
125+
return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType)
126+
127+
if isinstance(part, AudioContent):
128+
return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType)
129+
130+
if isinstance(part, EmbeddedResource):
131+
resource = part.resource
132+
if isinstance(resource, TextResourceContents):
133+
return resource.text
134+
135+
# BlobResourceContents
136+
return messages.BinaryContent(
137+
data=base64.b64decode(resource.blob),
138+
media_type=resource.mimeType or "application/octet-stream",
139+
)
140+
141+
msg = f"Unsupported/Unknown content block type: {type(part)}"
142+
raise ValueError(msg)

0 commit comments

Comments
 (0)