KingNish commited on
Commit
2407c30
·
1 Parent(s): a991154

Added MCP

Browse files
Files changed (5) hide show
  1. agent/__init__.py +8 -1
  2. agent/agent.py +20 -0
  3. agent/mcp.py +261 -0
  4. agent/tools.py +111 -17
  5. 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