Spaces:
Paused
Paused
Added MCP
Browse files- agent/__init__.py +8 -1
- agent/agent.py +20 -0
- agent/mcp.py +261 -0
- agent/tools.py +111 -17
- app.py +1 -0
agent/__init__.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
| 1 |
from .agent import Agent
|
| 2 |
-
from .tools import Tool, fetch_webpage, FETCH_WEBPAGE_TOOL
|
|
|
|
| 3 |
|
| 4 |
__all__ = [
|
| 5 |
"Agent",
|
| 6 |
"Tool",
|
|
|
|
| 7 |
"fetch_webpage",
|
| 8 |
"FETCH_WEBPAGE_TOOL",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
]
|
|
|
|
| 1 |
from .agent import Agent
|
| 2 |
+
from .tools import Tool, tool, fetch_webpage, FETCH_WEBPAGE_TOOL
|
| 3 |
+
from .mcp import MCPClient, mcp_tool, load_mcp_tools, load_all_mcp_tools, MCP_SERVERS
|
| 4 |
|
| 5 |
__all__ = [
|
| 6 |
"Agent",
|
| 7 |
"Tool",
|
| 8 |
+
"tool",
|
| 9 |
"fetch_webpage",
|
| 10 |
"FETCH_WEBPAGE_TOOL",
|
| 11 |
+
"MCPClient",
|
| 12 |
+
"mcp_tool",
|
| 13 |
+
"load_mcp_tools",
|
| 14 |
+
"load_all_mcp_tools",
|
| 15 |
+
"MCP_SERVERS",
|
| 16 |
]
|
agent/agent.py
CHANGED
|
@@ -4,6 +4,7 @@ from typing import Any, Generator
|
|
| 4 |
from openai import OpenAI
|
| 5 |
|
| 6 |
from .tools import Tool
|
|
|
|
| 7 |
|
| 8 |
# ---------------------------------------------------------------------------
|
| 9 |
# Streaming event contract
|
|
@@ -49,6 +50,25 @@ class Agent:
|
|
| 49 |
def register_tool(self, tool: Tool) -> None:
|
| 50 |
self._tools.append(tool)
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
def register_final_message_tool(self) -> None:
|
| 53 |
"""Register a no-input ``final_message`` tool the model **must** call
|
| 54 |
to signal that it is done.
|
|
|
|
| 4 |
from openai import OpenAI
|
| 5 |
|
| 6 |
from .tools import Tool
|
| 7 |
+
from .mcp import MCPClient, load_mcp_tools, load_all_mcp_tools
|
| 8 |
|
| 9 |
# ---------------------------------------------------------------------------
|
| 10 |
# Streaming event contract
|
|
|
|
| 50 |
def register_tool(self, tool: Tool) -> None:
|
| 51 |
self._tools.append(tool)
|
| 52 |
|
| 53 |
+
def register_mcp(self, url: str, headers: dict[str, str] | None = None) -> list[Tool]:
|
| 54 |
+
"""Connect to an MCP server and register all its tools.
|
| 55 |
+
|
| 56 |
+
Returns the list of registered Tool instances.
|
| 57 |
+
"""
|
| 58 |
+
tools = load_mcp_tools(url=url, headers=headers)
|
| 59 |
+
self._tools.extend(tools)
|
| 60 |
+
return tools
|
| 61 |
+
|
| 62 |
+
def register_all_mcp(self) -> dict[str, list[Tool]]:
|
| 63 |
+
"""Load tools from all pre-configured MCP servers.
|
| 64 |
+
|
| 65 |
+
Returns ``{server_name: [Tool, ...]}`` and registers them.
|
| 66 |
+
"""
|
| 67 |
+
all_tools = load_all_mcp_tools()
|
| 68 |
+
for server_tools in all_tools.values():
|
| 69 |
+
self._tools.extend(server_tools)
|
| 70 |
+
return all_tools
|
| 71 |
+
|
| 72 |
def register_final_message_tool(self) -> None:
|
| 73 |
"""Register a no-input ``final_message`` tool the model **must** call
|
| 74 |
to signal that it is done.
|
agent/mcp.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP (Model Context Protocol) client for remote tool servers.
|
| 2 |
+
|
| 3 |
+
Supports Streamable HTTP transport (JSON-RPC 2.0 over HTTP).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
from .tools import Tool
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# MCP Client
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MCPClient:
|
| 23 |
+
"""Connect to a remote MCP server via Streamable HTTP transport."""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
url: str,
|
| 28 |
+
headers: dict[str, str] | None = None,
|
| 29 |
+
timeout: float = 30.0,
|
| 30 |
+
) -> None:
|
| 31 |
+
self.url = url
|
| 32 |
+
self.headers = headers or {}
|
| 33 |
+
self.timeout = timeout
|
| 34 |
+
self._session_id: str | None = None
|
| 35 |
+
self._initialized = False
|
| 36 |
+
|
| 37 |
+
# ------------------------------------------------------------------
|
| 38 |
+
# Low-level JSON-RPC
|
| 39 |
+
# ------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
def _request(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
| 42 |
+
"""Send a JSON-RPC request and return the result."""
|
| 43 |
+
payload: dict[str, Any] = {
|
| 44 |
+
"jsonrpc": "2.0",
|
| 45 |
+
"id": str(uuid.uuid4()),
|
| 46 |
+
"method": method,
|
| 47 |
+
}
|
| 48 |
+
if params is not None:
|
| 49 |
+
payload["params"] = params
|
| 50 |
+
|
| 51 |
+
headers = {
|
| 52 |
+
"Content-Type": "application/json",
|
| 53 |
+
"Accept": "application/json, text/event-stream",
|
| 54 |
+
**self.headers,
|
| 55 |
+
}
|
| 56 |
+
if self._session_id:
|
| 57 |
+
headers["Mcp-Session-Id"] = self._session_id
|
| 58 |
+
|
| 59 |
+
with httpx.Client(timeout=self.timeout) as client:
|
| 60 |
+
resp = client.post(self.url, json=payload, headers=headers)
|
| 61 |
+
|
| 62 |
+
# Capture session ID from response
|
| 63 |
+
sid = resp.headers.get("mcp-session-id")
|
| 64 |
+
if sid:
|
| 65 |
+
self._session_id = sid
|
| 66 |
+
|
| 67 |
+
resp.raise_for_status()
|
| 68 |
+
content_type = resp.headers.get("content-type", "")
|
| 69 |
+
|
| 70 |
+
# Handle SSE response
|
| 71 |
+
if "text/event-stream" in content_type:
|
| 72 |
+
return self._parse_sse_response(resp.text)
|
| 73 |
+
|
| 74 |
+
# Handle JSON response
|
| 75 |
+
data = resp.json()
|
| 76 |
+
if "error" in data:
|
| 77 |
+
raise RuntimeError(f"MCP error: {data['error']}")
|
| 78 |
+
return data.get("result")
|
| 79 |
+
|
| 80 |
+
def _parse_sse_response(self, text: str) -> Any:
|
| 81 |
+
"""Parse SSE response text, extracting JSON-RPC results."""
|
| 82 |
+
result = None
|
| 83 |
+
for line in text.split("\n"):
|
| 84 |
+
line = line.strip()
|
| 85 |
+
if line.startswith("data:"):
|
| 86 |
+
data_str = line[len("data:") :].strip()
|
| 87 |
+
if data_str:
|
| 88 |
+
try:
|
| 89 |
+
data = json.loads(data_str)
|
| 90 |
+
if "result" in data:
|
| 91 |
+
result = data["result"]
|
| 92 |
+
elif "error" in data:
|
| 93 |
+
raise RuntimeError(f"MCP error: {data['error']}")
|
| 94 |
+
except json.JSONDecodeError:
|
| 95 |
+
continue
|
| 96 |
+
return result
|
| 97 |
+
|
| 98 |
+
# ------------------------------------------------------------------
|
| 99 |
+
# Lifecycle
|
| 100 |
+
# ------------------------------------------------------------------
|
| 101 |
+
|
| 102 |
+
def initialize(self) -> dict[str, Any]:
|
| 103 |
+
"""Initialize the MCP session."""
|
| 104 |
+
result = self._request(
|
| 105 |
+
"initialize",
|
| 106 |
+
{
|
| 107 |
+
"protocolVersion": "2025-03-26",
|
| 108 |
+
"capabilities": {},
|
| 109 |
+
"clientInfo": {"name": "mythos-agent", "version": "0.1.0"},
|
| 110 |
+
},
|
| 111 |
+
)
|
| 112 |
+
self._initialized = True
|
| 113 |
+
# Send initialized notification (no response expected)
|
| 114 |
+
self._notify("notifications/initialized", {})
|
| 115 |
+
return result or {}
|
| 116 |
+
|
| 117 |
+
def _notify(self, method: str, params: dict[str, Any]) -> None:
|
| 118 |
+
"""Send a JSON-RPC notification (no id, no response expected)."""
|
| 119 |
+
payload = {"jsonrpc": "2.0", "method": method, "params": params}
|
| 120 |
+
headers = {
|
| 121 |
+
"Content-Type": "application/json",
|
| 122 |
+
"Accept": "application/json, text/event-stream",
|
| 123 |
+
**self.headers,
|
| 124 |
+
}
|
| 125 |
+
if self._session_id:
|
| 126 |
+
headers["Mcp-Session-Id"] = self._session_id
|
| 127 |
+
|
| 128 |
+
with httpx.Client(timeout=self.timeout) as client:
|
| 129 |
+
client.post(self.url, json=payload, headers=headers)
|
| 130 |
+
|
| 131 |
+
# ------------------------------------------------------------------
|
| 132 |
+
# Tool discovery
|
| 133 |
+
# ------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
def list_tools(self) -> list[dict[str, Any]]:
|
| 136 |
+
"""List available tools from the server."""
|
| 137 |
+
if not self._initialized:
|
| 138 |
+
self.initialize()
|
| 139 |
+
result = self._request("tools/list", {})
|
| 140 |
+
return (result or {}).get("tools", [])
|
| 141 |
+
|
| 142 |
+
# ------------------------------------------------------------------
|
| 143 |
+
# Tool execution
|
| 144 |
+
# ------------------------------------------------------------------
|
| 145 |
+
|
| 146 |
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
|
| 147 |
+
"""Call a tool on the server."""
|
| 148 |
+
if not self._initialized:
|
| 149 |
+
self.initialize()
|
| 150 |
+
return self._request("tools/call", {"name": name, "arguments": arguments})
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ---------------------------------------------------------------------------
|
| 154 |
+
# MCP Tool wrapper
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _json_schema_type_to_python(type_str: str) -> type:
|
| 159 |
+
"""Map JSON Schema type to Python type."""
|
| 160 |
+
mapping = {"string": str, "integer": int, "number": float, "boolean": bool, "array": list, "object": dict}
|
| 161 |
+
return mapping.get(type_str, str)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def mcp_tool(client: MCPClient, mcp_tool_def: dict[str, Any]) -> Tool:
|
| 165 |
+
"""Wrap an MCP tool definition as a local Tool instance.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
client: Connected MCPClient.
|
| 169 |
+
mcp_tool_def: Tool dict from ``tools/list`` response.
|
| 170 |
+
"""
|
| 171 |
+
name = mcp_tool_def["name"]
|
| 172 |
+
description = mcp_tool_def.get("description", "")
|
| 173 |
+
input_schema = mcp_tool_def.get("inputSchema", {})
|
| 174 |
+
|
| 175 |
+
# Convert inputSchema to OpenAI-style parameters
|
| 176 |
+
properties: dict[str, dict] = {}
|
| 177 |
+
for param_name, param_def in input_schema.get("properties", {}).items():
|
| 178 |
+
prop: dict[str, Any] = {"type": param_def.get("type", "string")}
|
| 179 |
+
if "description" in param_def:
|
| 180 |
+
prop["description"] = param_def["description"]
|
| 181 |
+
if "enum" in param_def:
|
| 182 |
+
prop["enum"] = param_def["enum"]
|
| 183 |
+
properties[param_name] = prop
|
| 184 |
+
|
| 185 |
+
required = input_schema.get("required", [])
|
| 186 |
+
|
| 187 |
+
parameters = {
|
| 188 |
+
"type": "object",
|
| 189 |
+
"properties": properties,
|
| 190 |
+
"required": required,
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
def handler(**kwargs: Any) -> str:
|
| 194 |
+
result = client.call_tool(name, kwargs)
|
| 195 |
+
# Extract text content from MCP result
|
| 196 |
+
if isinstance(result, dict) and "content" in result:
|
| 197 |
+
parts = []
|
| 198 |
+
for block in result["content"]:
|
| 199 |
+
if isinstance(block, dict) and block.get("type") == "text":
|
| 200 |
+
parts.append(block.get("text", ""))
|
| 201 |
+
return "\n".join(parts) if parts else json.dumps(result)
|
| 202 |
+
return json.dumps(result) if not isinstance(result, str) else result
|
| 203 |
+
|
| 204 |
+
return Tool(name=name, description=description, parameters=parameters, handler=handler)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ---------------------------------------------------------------------------
|
| 208 |
+
# Convenience: connect and load all tools
|
| 209 |
+
# ---------------------------------------------------------------------------
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def load_mcp_tools(url: str, headers: dict[str, str] | None = None) -> list[Tool]:
|
| 213 |
+
"""Connect to an MCP server and return all its tools as Tool instances."""
|
| 214 |
+
client = MCPClient(url=url, headers=headers)
|
| 215 |
+
client.initialize()
|
| 216 |
+
tools_defs = client.list_tools()
|
| 217 |
+
return [mcp_tool(client, td) for td in tools_defs]
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
# Pre-configured MCP servers
|
| 222 |
+
# ---------------------------------------------------------------------------
|
| 223 |
+
|
| 224 |
+
MCP_SERVERS: dict[str, dict[str, Any]] = {
|
| 225 |
+
"exa": {
|
| 226 |
+
"url": "https://mcp.exa.ai/mcp",
|
| 227 |
+
"description": "Web search via Exa",
|
| 228 |
+
},
|
| 229 |
+
"context7": {
|
| 230 |
+
"url": "https://mcp.context7.com/mcp",
|
| 231 |
+
"description": "Library documentation lookup",
|
| 232 |
+
},
|
| 233 |
+
"grep_app": {
|
| 234 |
+
"url": "https://mcp.grep.app",
|
| 235 |
+
"description": "GitHub code search via grep.app",
|
| 236 |
+
},
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def load_all_mcp_tools(
|
| 241 |
+
servers: dict[str, dict[str, Any]] | None = None,
|
| 242 |
+
) -> dict[str, list[Tool]]:
|
| 243 |
+
"""Load tools from all configured MCP servers.
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
``{server_name: [Tool, ...]}``
|
| 247 |
+
"""
|
| 248 |
+
if servers is None:
|
| 249 |
+
servers = MCP_SERVERS
|
| 250 |
+
|
| 251 |
+
result: dict[str, list[Tool]] = {}
|
| 252 |
+
for name, cfg in servers.items():
|
| 253 |
+
try:
|
| 254 |
+
result[name] = load_mcp_tools(
|
| 255 |
+
url=cfg["url"],
|
| 256 |
+
headers=cfg.get("headers"),
|
| 257 |
+
)
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print(f"[MCP] Failed to load {name}: {e}")
|
| 260 |
+
result[name] = []
|
| 261 |
+
return result
|
agent/tools.py
CHANGED
|
@@ -1,7 +1,21 @@
|
|
| 1 |
from markitdown import MarkItDown
|
| 2 |
-
from typing import Any, Callable
|
|
|
|
| 3 |
import requests
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
class Tool:
|
| 6 |
"""A callable tool the agent can invoke."""
|
| 7 |
|
|
@@ -27,8 +41,102 @@ class Tool:
|
|
| 27 |
return self.handler(**kwargs)
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def fetch_webpage(url: str) -> str:
|
| 31 |
-
"""Fetch a webpage and return its text content.
|
|
|
|
|
|
|
|
|
|
| 32 |
try:
|
| 33 |
jina_ai_url = "https://r.jina.ai/"
|
| 34 |
response = requests.get(jina_ai_url + url)
|
|
@@ -38,18 +146,4 @@ def fetch_webpage(url: str) -> str:
|
|
| 38 |
md = MarkItDown()
|
| 39 |
return md.convert(url).text_content
|
| 40 |
|
| 41 |
-
FETCH_WEBPAGE_TOOL = Tool
|
| 42 |
-
name="fetch_webpage",
|
| 43 |
-
description="Fetch the content of a webpage and return its text",
|
| 44 |
-
parameters={
|
| 45 |
-
"type": "object",
|
| 46 |
-
"properties": {
|
| 47 |
-
"url": {
|
| 48 |
-
"type": "string",
|
| 49 |
-
"description": "The URL to fetch",
|
| 50 |
-
}
|
| 51 |
-
},
|
| 52 |
-
"required": ["url"],
|
| 53 |
-
},
|
| 54 |
-
handler=fetch_webpage,
|
| 55 |
-
)
|
|
|
|
| 1 |
from markitdown import MarkItDown
|
| 2 |
+
from typing import Any, Callable, get_type_hints
|
| 3 |
+
import inspect
|
| 4 |
import requests
|
| 5 |
|
| 6 |
+
|
| 7 |
+
def python_type_to_json_schema(tp: type) -> str:
|
| 8 |
+
"""Map a Python type to a JSON Schema type string."""
|
| 9 |
+
mapping = {
|
| 10 |
+
str: "string",
|
| 11 |
+
int: "integer",
|
| 12 |
+
float: "number",
|
| 13 |
+
bool: "boolean",
|
| 14 |
+
list: "array",
|
| 15 |
+
dict: "object",
|
| 16 |
+
}
|
| 17 |
+
return mapping.get(tp, "string")
|
| 18 |
+
|
| 19 |
class Tool:
|
| 20 |
"""A callable tool the agent can invoke."""
|
| 21 |
|
|
|
|
| 41 |
return self.handler(**kwargs)
|
| 42 |
|
| 43 |
|
| 44 |
+
def _parse_docstring(docstring: str) -> tuple[str, dict[str, tuple[bool, str]]]:
|
| 45 |
+
"""Parse a tool docstring into description and param metadata.
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
(description, {param_name: (required, description)})
|
| 49 |
+
|
| 50 |
+
Expected format:
|
| 51 |
+
First line: tool description.
|
| 52 |
+
Subsequent lines: ``param_name (required): description`` or
|
| 53 |
+
``param_name: description``.
|
| 54 |
+
"""
|
| 55 |
+
lines = (docstring or "").strip().split("\n")
|
| 56 |
+
description = lines[0].strip()
|
| 57 |
+
param_info: dict[str, tuple[bool, str]] = {}
|
| 58 |
+
|
| 59 |
+
for line in lines[1:]:
|
| 60 |
+
line = line.strip()
|
| 61 |
+
if not line:
|
| 62 |
+
continue
|
| 63 |
+
# Match: param_name (required): description
|
| 64 |
+
# or: param_name: description
|
| 65 |
+
if ":" not in line:
|
| 66 |
+
continue
|
| 67 |
+
key, desc = line.split(":", 1)
|
| 68 |
+
key = key.strip()
|
| 69 |
+
desc = desc.strip()
|
| 70 |
+
required = False
|
| 71 |
+
if key.endswith("(required)"):
|
| 72 |
+
required = True
|
| 73 |
+
key = key[: -len("(required)")].strip()
|
| 74 |
+
if key:
|
| 75 |
+
param_info[key] = (required, desc)
|
| 76 |
+
|
| 77 |
+
return description, param_info
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def tool(fn: Callable[..., str]) -> Tool:
|
| 81 |
+
"""Decorator that converts a function into a Tool instance.
|
| 82 |
+
|
| 83 |
+
Extracts name, description (first line of docstring), and parameters
|
| 84 |
+
from the function's type hints and signature.
|
| 85 |
+
|
| 86 |
+
Docstring format:
|
| 87 |
+
First line: tool description.
|
| 88 |
+
Subsequent lines: ``param_name (required): description`` or
|
| 89 |
+
``param_name: description``.
|
| 90 |
+
"""
|
| 91 |
+
name = fn.__name__
|
| 92 |
+
docstring = fn.__doc__ or ""
|
| 93 |
+
description, param_info = _parse_docstring(docstring)
|
| 94 |
+
|
| 95 |
+
hints = get_type_hints(fn)
|
| 96 |
+
sig = inspect.signature(fn)
|
| 97 |
+
|
| 98 |
+
properties: dict[str, dict] = {}
|
| 99 |
+
required: list[str] = []
|
| 100 |
+
|
| 101 |
+
for param_name, param in sig.parameters.items():
|
| 102 |
+
if param_name in hints:
|
| 103 |
+
param_schema: dict[str, Any] = {
|
| 104 |
+
"type": python_type_to_json_schema(hints[param_name])
|
| 105 |
+
}
|
| 106 |
+
# Enrich with docstring info if present
|
| 107 |
+
if param_name in param_info:
|
| 108 |
+
doc_required, doc_desc = param_info[param_name]
|
| 109 |
+
if doc_desc:
|
| 110 |
+
param_schema["description"] = doc_desc
|
| 111 |
+
# Docstring (required) overrides signature default check
|
| 112 |
+
if doc_required:
|
| 113 |
+
required.append(param_name)
|
| 114 |
+
elif param.default is inspect.Parameter.empty:
|
| 115 |
+
required.append(param_name)
|
| 116 |
+
elif param.default is inspect.Parameter.empty:
|
| 117 |
+
required.append(param_name)
|
| 118 |
+
properties[param_name] = param_schema
|
| 119 |
+
|
| 120 |
+
parameters = {
|
| 121 |
+
"type": "object",
|
| 122 |
+
"properties": properties,
|
| 123 |
+
"required": required,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
return Tool(
|
| 127 |
+
name=name,
|
| 128 |
+
description=description,
|
| 129 |
+
parameters=parameters,
|
| 130 |
+
handler=fn,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@tool
|
| 135 |
def fetch_webpage(url: str) -> str:
|
| 136 |
+
"""Fetch a webpage and return its text content.
|
| 137 |
+
|
| 138 |
+
url (required): The URL to fetch
|
| 139 |
+
"""
|
| 140 |
try:
|
| 141 |
jina_ai_url = "https://r.jina.ai/"
|
| 142 |
response = requests.get(jina_ai_url + url)
|
|
|
|
| 146 |
md = MarkItDown()
|
| 147 |
return md.convert(url).text_content
|
| 148 |
|
| 149 |
+
FETCH_WEBPAGE_TOOL = fetch_webpage # @tool already makes it a Tool instance
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -31,6 +31,7 @@ agent = Agent(
|
|
| 31 |
system_prompt=_SYSTEM_PROMPT,
|
| 32 |
)
|
| 33 |
agent.register_tool(FETCH_WEBPAGE_TOOL)
|
|
|
|
| 34 |
agent.register_final_message_tool()
|
| 35 |
|
| 36 |
# Load JS from external files
|
|
|
|
| 31 |
system_prompt=_SYSTEM_PROMPT,
|
| 32 |
)
|
| 33 |
agent.register_tool(FETCH_WEBPAGE_TOOL)
|
| 34 |
+
agent.register_all_mcp()
|
| 35 |
agent.register_final_message_tool()
|
| 36 |
|
| 37 |
# Load JS from external files
|