ida-pro-mcp-xjoker 1.0.1__py3-none-any.whl
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.
- ida_pro_mcp/__init__.py +0 -0
- ida_pro_mcp/__main__.py +6 -0
- ida_pro_mcp/ida_mcp/__init__.py +68 -0
- ida_pro_mcp/ida_mcp/api_analysis.py +1296 -0
- ida_pro_mcp/ida_mcp/api_core.py +337 -0
- ida_pro_mcp/ida_mcp/api_debug.py +617 -0
- ida_pro_mcp/ida_mcp/api_memory.py +304 -0
- ida_pro_mcp/ida_mcp/api_modify.py +406 -0
- ida_pro_mcp/ida_mcp/api_python.py +179 -0
- ida_pro_mcp/ida_mcp/api_resources.py +295 -0
- ida_pro_mcp/ida_mcp/api_stack.py +167 -0
- ida_pro_mcp/ida_mcp/api_types.py +480 -0
- ida_pro_mcp/ida_mcp/auth.py +166 -0
- ida_pro_mcp/ida_mcp/cache.py +232 -0
- ida_pro_mcp/ida_mcp/config.py +228 -0
- ida_pro_mcp/ida_mcp/framework.py +547 -0
- ida_pro_mcp/ida_mcp/http.py +859 -0
- ida_pro_mcp/ida_mcp/port_utils.py +104 -0
- ida_pro_mcp/ida_mcp/rpc.py +187 -0
- ida_pro_mcp/ida_mcp/server_manager.py +339 -0
- ida_pro_mcp/ida_mcp/sync.py +233 -0
- ida_pro_mcp/ida_mcp/tests/__init__.py +14 -0
- ida_pro_mcp/ida_mcp/tests/test_api_analysis.py +336 -0
- ida_pro_mcp/ida_mcp/tests/test_api_core.py +237 -0
- ida_pro_mcp/ida_mcp/tests/test_api_memory.py +207 -0
- ida_pro_mcp/ida_mcp/tests/test_api_modify.py +123 -0
- ida_pro_mcp/ida_mcp/tests/test_api_resources.py +199 -0
- ida_pro_mcp/ida_mcp/tests/test_api_stack.py +77 -0
- ida_pro_mcp/ida_mcp/tests/test_api_types.py +249 -0
- ida_pro_mcp/ida_mcp/ui.py +357 -0
- ida_pro_mcp/ida_mcp/utils.py +1186 -0
- ida_pro_mcp/ida_mcp/zeromcp/__init__.py +5 -0
- ida_pro_mcp/ida_mcp/zeromcp/jsonrpc.py +384 -0
- ida_pro_mcp/ida_mcp/zeromcp/mcp.py +883 -0
- ida_pro_mcp/ida_mcp.py +186 -0
- ida_pro_mcp/idalib_server.py +354 -0
- ida_pro_mcp/idalib_session_manager.py +259 -0
- ida_pro_mcp/server.py +1060 -0
- ida_pro_mcp/test.py +170 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/METADATA +405 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/RECORD +45 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/WHEEL +5 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/entry_points.txt +4 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/licenses/LICENSE +21 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
import json
|
|
6
|
+
import gzip
|
|
7
|
+
import inspect
|
|
8
|
+
import threading
|
|
9
|
+
import traceback
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer, HTTPServer
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
Callable,
|
|
14
|
+
Union,
|
|
15
|
+
Annotated,
|
|
16
|
+
BinaryIO,
|
|
17
|
+
NotRequired,
|
|
18
|
+
get_origin,
|
|
19
|
+
get_args,
|
|
20
|
+
get_type_hints,
|
|
21
|
+
is_typeddict,
|
|
22
|
+
)
|
|
23
|
+
from types import UnionType
|
|
24
|
+
from urllib.parse import urlparse, parse_qs
|
|
25
|
+
from io import BufferedIOBase
|
|
26
|
+
|
|
27
|
+
from .jsonrpc import (
|
|
28
|
+
JsonRpcRegistry,
|
|
29
|
+
JsonRpcError,
|
|
30
|
+
JsonRpcException,
|
|
31
|
+
get_current_request_id,
|
|
32
|
+
register_pending_request,
|
|
33
|
+
unregister_pending_request,
|
|
34
|
+
cancel_request,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Compression settings
|
|
38
|
+
COMPRESSION_THRESHOLD = 1024 # Only compress responses > 1KB
|
|
39
|
+
COMPRESSION_LEVEL = 6 # Balanced speed/ratio (1-9)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class McpToolError(Exception):
|
|
43
|
+
def __init__(self, message: str):
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class McpRpcRegistry(JsonRpcRegistry):
|
|
48
|
+
"""JSON-RPC registry with custom error handling for MCP tools"""
|
|
49
|
+
|
|
50
|
+
def map_exception(self, e: Exception) -> JsonRpcError:
|
|
51
|
+
if isinstance(e, McpToolError):
|
|
52
|
+
return {
|
|
53
|
+
"code": -32000,
|
|
54
|
+
"message": e.args[0] or "MCP Tool Error",
|
|
55
|
+
}
|
|
56
|
+
return super().map_exception(e)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _McpSseConnection:
|
|
60
|
+
"""Manages a single SSE client connection"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, wfile):
|
|
63
|
+
self.wfile: BufferedIOBase = wfile
|
|
64
|
+
self.session_id = str(uuid.uuid4())
|
|
65
|
+
self.alive = True
|
|
66
|
+
|
|
67
|
+
def send_event(self, event_type: str, data):
|
|
68
|
+
"""Send an SSE event to the client
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
event_type: Type of event (e.g., "endpoint", "message", "ping")
|
|
72
|
+
data: Event data - can be string (sent as-is) or dict (JSON-encoded)
|
|
73
|
+
"""
|
|
74
|
+
if not self.alive:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
# SSE format: "event: type\ndata: content\n\n"
|
|
79
|
+
if isinstance(data, str):
|
|
80
|
+
data_str = f"data: {data}\n\n"
|
|
81
|
+
else:
|
|
82
|
+
data_str = f"data: {json.dumps(data)}\n\n"
|
|
83
|
+
message = f"event: {event_type}\n{data_str}".encode("utf-8")
|
|
84
|
+
self.wfile.write(message)
|
|
85
|
+
self.wfile.flush() # Ensure data is sent immediately
|
|
86
|
+
return True
|
|
87
|
+
except (BrokenPipeError, OSError):
|
|
88
|
+
self.alive = False
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class McpHttpRequestHandler(BaseHTTPRequestHandler):
|
|
93
|
+
server_version = "zeromcp/1.3.0"
|
|
94
|
+
error_message_format = "%(code)d - %(message)s"
|
|
95
|
+
error_content_type = "text/plain"
|
|
96
|
+
|
|
97
|
+
def __init__(self, request, client_address, server):
|
|
98
|
+
self.mcp_server: "McpServer" = getattr(server, "mcp_server")
|
|
99
|
+
super().__init__(request, client_address, server)
|
|
100
|
+
|
|
101
|
+
def _parse_extensions(self, path: str) -> set[str]:
|
|
102
|
+
"""Parse ?ext=dbg,foo query param into set of enabled extensions"""
|
|
103
|
+
query = parse_qs(urlparse(path).query)
|
|
104
|
+
ext_param = query.get("ext", [""])[0]
|
|
105
|
+
if not ext_param:
|
|
106
|
+
return set()
|
|
107
|
+
return {e.strip() for e in ext_param.split(",") if e.strip()}
|
|
108
|
+
|
|
109
|
+
def log_message(self, format, *args):
|
|
110
|
+
"""Override to suppress default logging or customize"""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def send_cors_headers(self, *, preflight=False):
|
|
114
|
+
origin = self.headers.get("Origin", "")
|
|
115
|
+
if not origin:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
def is_allowed():
|
|
119
|
+
allowed = self.mcp_server.cors_allowed_origins
|
|
120
|
+
if allowed is None:
|
|
121
|
+
return False
|
|
122
|
+
if callable(allowed):
|
|
123
|
+
return allowed(origin)
|
|
124
|
+
if isinstance(allowed, str):
|
|
125
|
+
allowed = [allowed]
|
|
126
|
+
assert isinstance(allowed, list)
|
|
127
|
+
return "*" in allowed or origin in allowed
|
|
128
|
+
|
|
129
|
+
if not is_allowed():
|
|
130
|
+
return
|
|
131
|
+
self.send_header("Access-Control-Allow-Origin", origin)
|
|
132
|
+
if preflight:
|
|
133
|
+
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
134
|
+
self.send_header(
|
|
135
|
+
"Access-Control-Allow-Headers",
|
|
136
|
+
"Content-Type, Accept, X-Requested-With, Mcp-Session-Id, Mcp-Protocol-Version",
|
|
137
|
+
)
|
|
138
|
+
if self.headers.get("Access-Control-Request-Private-Network") == "true":
|
|
139
|
+
self.send_header("Access-Control-Allow-Private-Network", "true")
|
|
140
|
+
|
|
141
|
+
def send_error(self, code, message=None, explain=None):
|
|
142
|
+
self.send_response(code)
|
|
143
|
+
self.send_header("Content-Type", "text/plain")
|
|
144
|
+
self.send_cors_headers()
|
|
145
|
+
self.end_headers()
|
|
146
|
+
self.wfile.write(f"{message}\n".encode("utf-8"))
|
|
147
|
+
|
|
148
|
+
def handle(self):
|
|
149
|
+
"""Override to add error handling for connection errors"""
|
|
150
|
+
try:
|
|
151
|
+
super().handle()
|
|
152
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
|
|
153
|
+
# Client disconnected - normal, suppress traceback
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
def do_GET(self):
|
|
157
|
+
match urlparse(self.path).path:
|
|
158
|
+
case "/sse":
|
|
159
|
+
self._handle_sse_get()
|
|
160
|
+
case "/mcp":
|
|
161
|
+
self.send_error(405, "Method Not Allowed")
|
|
162
|
+
case _:
|
|
163
|
+
self.send_error(404, "Not Found")
|
|
164
|
+
|
|
165
|
+
def do_POST(self):
|
|
166
|
+
# Read request body
|
|
167
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
168
|
+
|
|
169
|
+
if content_length > self.mcp_server.post_body_limit:
|
|
170
|
+
self.send_error(
|
|
171
|
+
413,
|
|
172
|
+
f"Payload Too Large: exceeds {self.mcp_server.post_body_limit} bytes",
|
|
173
|
+
)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
body = self.rfile.read(content_length) if content_length > 0 else b""
|
|
177
|
+
|
|
178
|
+
match urlparse(self.path).path:
|
|
179
|
+
case "/sse":
|
|
180
|
+
self._handle_sse_post(body)
|
|
181
|
+
case "/mcp":
|
|
182
|
+
self._handle_mcp_post(body)
|
|
183
|
+
case _:
|
|
184
|
+
self.send_error(404, "Not Found")
|
|
185
|
+
|
|
186
|
+
def do_OPTIONS(self):
|
|
187
|
+
"""Handle CORS preflight requests"""
|
|
188
|
+
self.send_response(200)
|
|
189
|
+
self.send_cors_headers(preflight=True)
|
|
190
|
+
self.end_headers()
|
|
191
|
+
|
|
192
|
+
def _handle_sse_get(self):
|
|
193
|
+
# Create SSE connection wrapper
|
|
194
|
+
conn = _McpSseConnection(self.wfile)
|
|
195
|
+
self.mcp_server._sse_connections[conn.session_id] = conn
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
# Send SSE headers
|
|
199
|
+
self.send_response(200)
|
|
200
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
201
|
+
self.send_header("Cache-Control", "no-cache")
|
|
202
|
+
self.send_header("Connection", "keep-alive")
|
|
203
|
+
self.send_cors_headers()
|
|
204
|
+
self.end_headers()
|
|
205
|
+
|
|
206
|
+
# Send endpoint event with session ID for routing
|
|
207
|
+
conn.send_event("endpoint", f"/sse?session={conn.session_id}")
|
|
208
|
+
|
|
209
|
+
# Keep connection alive with periodic pings
|
|
210
|
+
last_ping = time.time()
|
|
211
|
+
while conn.alive and self.mcp_server._running:
|
|
212
|
+
now = time.time()
|
|
213
|
+
if now - last_ping > 30: # Ping every 30 seconds
|
|
214
|
+
if not conn.send_event("ping", {}):
|
|
215
|
+
break
|
|
216
|
+
last_ping = now
|
|
217
|
+
time.sleep(1)
|
|
218
|
+
|
|
219
|
+
finally:
|
|
220
|
+
conn.alive = False
|
|
221
|
+
if conn.session_id in self.mcp_server._sse_connections:
|
|
222
|
+
del self.mcp_server._sse_connections[conn.session_id]
|
|
223
|
+
|
|
224
|
+
def _handle_sse_post(self, body: bytes):
|
|
225
|
+
query_params = parse_qs(urlparse(self.path).query)
|
|
226
|
+
session_id = query_params.get("session", [None])[0]
|
|
227
|
+
if session_id is None:
|
|
228
|
+
self.send_error(400, "Missing ?session for SSE POST")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# Parse extensions from query params and store in thread-local
|
|
232
|
+
extensions = self._parse_extensions(self.path)
|
|
233
|
+
setattr(self.mcp_server._enabled_extensions, "data", extensions)
|
|
234
|
+
|
|
235
|
+
# Dispatch to MCP registry
|
|
236
|
+
setattr(self.mcp_server._protocol_version, "data", "2024-11-05")
|
|
237
|
+
response = self.mcp_server.registry.dispatch(body)
|
|
238
|
+
|
|
239
|
+
# Send SSE response if necessary
|
|
240
|
+
if response is not None:
|
|
241
|
+
sse_conn = self.mcp_server._sse_connections.get(session_id)
|
|
242
|
+
if sse_conn is None or not sse_conn.alive:
|
|
243
|
+
# No SSE connection found
|
|
244
|
+
self.send_error(
|
|
245
|
+
400, f"No active SSE connection found for session {session_id}"
|
|
246
|
+
)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
# Send response via SSE event stream
|
|
250
|
+
sse_conn.send_event("message", response)
|
|
251
|
+
|
|
252
|
+
# Return 202 Accepted to acknowledge POST
|
|
253
|
+
self.send_response(202)
|
|
254
|
+
self.send_header("Content-Type", "application/json")
|
|
255
|
+
self.send_header("Content-Length", str(len(body)))
|
|
256
|
+
self.send_cors_headers()
|
|
257
|
+
self.end_headers()
|
|
258
|
+
self.wfile.write(body)
|
|
259
|
+
|
|
260
|
+
def _handle_mcp_post(self, body: bytes):
|
|
261
|
+
# Parse extensions from query params and store in thread-local
|
|
262
|
+
extensions = self._parse_extensions(self.path)
|
|
263
|
+
setattr(self.mcp_server._enabled_extensions, "data", extensions)
|
|
264
|
+
|
|
265
|
+
# Check if client accepts gzip encoding
|
|
266
|
+
accept_encoding = self.headers.get("Accept-Encoding", "")
|
|
267
|
+
supports_gzip = "gzip" in accept_encoding.lower()
|
|
268
|
+
|
|
269
|
+
# Dispatch to MCP registry
|
|
270
|
+
setattr(self.mcp_server._protocol_version, "data", "2025-06-18")
|
|
271
|
+
response = self.mcp_server.registry.dispatch(body)
|
|
272
|
+
|
|
273
|
+
def send_response(
|
|
274
|
+
status: int, response_body: bytes, content_type: str = "application/json"
|
|
275
|
+
):
|
|
276
|
+
# Try to compress if beneficial
|
|
277
|
+
use_gzip = False
|
|
278
|
+
if supports_gzip and len(response_body) > COMPRESSION_THRESHOLD:
|
|
279
|
+
compressed = gzip.compress(
|
|
280
|
+
response_body, compresslevel=COMPRESSION_LEVEL
|
|
281
|
+
)
|
|
282
|
+
# Only use compression if it actually reduces size significantly
|
|
283
|
+
if len(compressed) < len(response_body) * 0.9:
|
|
284
|
+
response_body = compressed
|
|
285
|
+
use_gzip = True
|
|
286
|
+
|
|
287
|
+
self.send_response(status)
|
|
288
|
+
self.send_header("Content-Type", content_type)
|
|
289
|
+
self.send_header("Content-Length", str(len(response_body)))
|
|
290
|
+
if use_gzip:
|
|
291
|
+
self.send_header("Content-Encoding", "gzip")
|
|
292
|
+
self.send_cors_headers()
|
|
293
|
+
self.end_headers()
|
|
294
|
+
self.wfile.write(response_body)
|
|
295
|
+
|
|
296
|
+
# Check if notification (returns None)
|
|
297
|
+
if response is None:
|
|
298
|
+
send_response(202, b"Accepted", "text/plain")
|
|
299
|
+
else:
|
|
300
|
+
send_response(200, json.dumps(response).encode("utf-8"))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class McpServer:
|
|
304
|
+
def __init__(
|
|
305
|
+
self,
|
|
306
|
+
name: str,
|
|
307
|
+
version="1.0.0",
|
|
308
|
+
*,
|
|
309
|
+
extensions: dict[str, set[str]] | None = None,
|
|
310
|
+
):
|
|
311
|
+
self.name = name
|
|
312
|
+
self.version = version
|
|
313
|
+
self.cors_allowed_origins: Callable[[str], bool] | list[str] | str | None = (
|
|
314
|
+
self.cors_localhost
|
|
315
|
+
)
|
|
316
|
+
self.post_body_limit = 10 * 1024 * 1024 # 10MB
|
|
317
|
+
self.tools = McpRpcRegistry()
|
|
318
|
+
self.resources = McpRpcRegistry()
|
|
319
|
+
self.prompts = McpRpcRegistry()
|
|
320
|
+
|
|
321
|
+
self._http_server: HTTPServer | None = None
|
|
322
|
+
self._server_thread: threading.Thread | None = None
|
|
323
|
+
self._running = False
|
|
324
|
+
self._sse_connections: dict[str, _McpSseConnection] = {}
|
|
325
|
+
self._protocol_version = threading.local()
|
|
326
|
+
self._enabled_extensions = threading.local() # set[str] per request
|
|
327
|
+
self._extensions_registry = (
|
|
328
|
+
extensions if extensions is not None else {}
|
|
329
|
+
) # group -> set of tool names
|
|
330
|
+
|
|
331
|
+
# Register MCP protocol methods with correct names
|
|
332
|
+
self.registry = JsonRpcRegistry()
|
|
333
|
+
self.registry.methods["ping"] = self._mcp_ping
|
|
334
|
+
self.registry.methods["initialize"] = self._mcp_initialize
|
|
335
|
+
self.registry.methods["tools/list"] = self._mcp_tools_list
|
|
336
|
+
self.registry.methods["tools/call"] = self._mcp_tools_call
|
|
337
|
+
self.registry.methods["resources/list"] = self._mcp_resources_list
|
|
338
|
+
self.registry.methods["resources/templates/list"] = (
|
|
339
|
+
self._mcp_resource_templates_list
|
|
340
|
+
)
|
|
341
|
+
self.registry.methods["resources/read"] = self._mcp_resources_read
|
|
342
|
+
self.registry.methods["prompts/list"] = self._mcp_prompts_list
|
|
343
|
+
self.registry.methods["prompts/get"] = self._mcp_prompts_get
|
|
344
|
+
self.registry.methods["notifications/cancelled"] = (
|
|
345
|
+
self._mcp_notifications_cancelled
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def tool(self, func: Callable) -> Callable:
|
|
349
|
+
return self.tools.method(func)
|
|
350
|
+
|
|
351
|
+
def resource(self, uri: str) -> Callable[[Callable], Callable]:
|
|
352
|
+
def decorator(func: Callable) -> Callable:
|
|
353
|
+
setattr(func, "__resource_uri__", uri)
|
|
354
|
+
return self.resources.method(func)
|
|
355
|
+
|
|
356
|
+
return decorator
|
|
357
|
+
|
|
358
|
+
def prompt(self, func: Callable) -> Callable:
|
|
359
|
+
return self.prompts.method(func)
|
|
360
|
+
|
|
361
|
+
def serve(
|
|
362
|
+
self,
|
|
363
|
+
host: str,
|
|
364
|
+
port: int,
|
|
365
|
+
*,
|
|
366
|
+
background=True,
|
|
367
|
+
request_handler=McpHttpRequestHandler,
|
|
368
|
+
):
|
|
369
|
+
if self._running:
|
|
370
|
+
print("[MCP] Server is already running")
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
# Create server with deferred binding
|
|
374
|
+
assert issubclass(request_handler, McpHttpRequestHandler)
|
|
375
|
+
self._http_server = (ThreadingHTTPServer if background else HTTPServer)(
|
|
376
|
+
(host, port), request_handler, bind_and_activate=False
|
|
377
|
+
)
|
|
378
|
+
self._http_server.allow_reuse_address = True
|
|
379
|
+
if hasattr(self._http_server, "allow_reuse_port"):
|
|
380
|
+
self._http_server.allow_reuse_port = True
|
|
381
|
+
|
|
382
|
+
# Set the MCPServer instance on the handler class
|
|
383
|
+
setattr(self._http_server, "mcp_server", self)
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
# Bind and activate in main thread - errors propagate synchronously
|
|
387
|
+
self._http_server.server_bind()
|
|
388
|
+
self._http_server.server_activate()
|
|
389
|
+
except OSError:
|
|
390
|
+
# Cleanup on binding failure
|
|
391
|
+
self._http_server.server_close()
|
|
392
|
+
self._http_server = None
|
|
393
|
+
raise
|
|
394
|
+
|
|
395
|
+
# Only start thread after successful bind
|
|
396
|
+
self._running = True
|
|
397
|
+
|
|
398
|
+
print("[MCP] Server started:")
|
|
399
|
+
print(f" Streamable HTTP: http://{host}:{port}/mcp")
|
|
400
|
+
print(f" SSE: http://{host}:{port}/sse")
|
|
401
|
+
|
|
402
|
+
def serve_forever():
|
|
403
|
+
try:
|
|
404
|
+
self._http_server.serve_forever() # type: ignore
|
|
405
|
+
except Exception as e:
|
|
406
|
+
print(f"[MCP] Server error: {e}")
|
|
407
|
+
traceback.print_exc()
|
|
408
|
+
finally:
|
|
409
|
+
self._running = False
|
|
410
|
+
|
|
411
|
+
if background:
|
|
412
|
+
self._server_thread = threading.Thread(target=serve_forever, daemon=True)
|
|
413
|
+
self._server_thread.start()
|
|
414
|
+
else:
|
|
415
|
+
serve_forever()
|
|
416
|
+
|
|
417
|
+
def stop(self):
|
|
418
|
+
if not self._running:
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
self._running = False
|
|
422
|
+
|
|
423
|
+
# Close all SSE connections
|
|
424
|
+
for conn in self._sse_connections.values():
|
|
425
|
+
conn.alive = False
|
|
426
|
+
self._sse_connections.clear()
|
|
427
|
+
|
|
428
|
+
# Shutdown the HTTP server
|
|
429
|
+
if self._http_server:
|
|
430
|
+
# shutdown() must be called from a different thread
|
|
431
|
+
# than the one running serve_forever()
|
|
432
|
+
self._http_server.shutdown()
|
|
433
|
+
self._http_server.server_close()
|
|
434
|
+
self._http_server = None
|
|
435
|
+
|
|
436
|
+
if self._server_thread:
|
|
437
|
+
self._server_thread.join()
|
|
438
|
+
self._server_thread = None
|
|
439
|
+
|
|
440
|
+
print("[MCP] Server stopped")
|
|
441
|
+
|
|
442
|
+
def stdio(self, stdin: BinaryIO | None = None, stdout: BinaryIO | None = None):
|
|
443
|
+
stdin = stdin or sys.stdin.buffer
|
|
444
|
+
stdout = stdout or sys.stdout.buffer
|
|
445
|
+
while True:
|
|
446
|
+
try:
|
|
447
|
+
request = stdin.readline()
|
|
448
|
+
if not request: # EOF
|
|
449
|
+
break
|
|
450
|
+
|
|
451
|
+
# Strip whitespace (trailing newline) before parsing
|
|
452
|
+
request = request.strip()
|
|
453
|
+
if not request:
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
response = self.registry.dispatch(request)
|
|
457
|
+
if response is not None:
|
|
458
|
+
stdout.write(json.dumps(response).encode("utf-8") + b"\n")
|
|
459
|
+
stdout.flush()
|
|
460
|
+
except (BrokenPipeError, KeyboardInterrupt): # Client disconnected
|
|
461
|
+
break
|
|
462
|
+
|
|
463
|
+
def cors_localhost(self, origin: str) -> bool:
|
|
464
|
+
"""Allow CORS requests from localhost on ANY port."""
|
|
465
|
+
return urlparse(origin).hostname in ("localhost", "127.0.0.1", "::1")
|
|
466
|
+
|
|
467
|
+
def _mcp_ping(self, _meta: dict | None = None) -> dict:
|
|
468
|
+
"""MCP ping method"""
|
|
469
|
+
return {}
|
|
470
|
+
|
|
471
|
+
def _mcp_initialize(
|
|
472
|
+
self,
|
|
473
|
+
protocolVersion: str,
|
|
474
|
+
capabilities: dict,
|
|
475
|
+
clientInfo: dict,
|
|
476
|
+
_meta: dict | None = None,
|
|
477
|
+
) -> dict:
|
|
478
|
+
"""MCP initialize method"""
|
|
479
|
+
return {
|
|
480
|
+
"protocolVersion": getattr(self._protocol_version, "data", protocolVersion),
|
|
481
|
+
"capabilities": {
|
|
482
|
+
"tools": {},
|
|
483
|
+
"resources": {
|
|
484
|
+
"subscribe": False,
|
|
485
|
+
"listChanged": False,
|
|
486
|
+
},
|
|
487
|
+
"prompts": {},
|
|
488
|
+
},
|
|
489
|
+
"serverInfo": {
|
|
490
|
+
"name": self.name,
|
|
491
|
+
"version": self.version,
|
|
492
|
+
},
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
def _mcp_tools_list(self, _meta: dict | None = None) -> dict:
|
|
496
|
+
"""MCP tools/list method"""
|
|
497
|
+
enabled = getattr(self._enabled_extensions, "data", set())
|
|
498
|
+
tools = []
|
|
499
|
+
for func_name, func in self.tools.methods.items():
|
|
500
|
+
# Check if tool belongs to an extension group
|
|
501
|
+
tool_group = self._get_tool_extension(func_name)
|
|
502
|
+
if tool_group and tool_group not in enabled:
|
|
503
|
+
continue # Skip tools from disabled extension groups
|
|
504
|
+
tools.append(self._generate_tool_schema(func_name, func))
|
|
505
|
+
return {"tools": tools}
|
|
506
|
+
|
|
507
|
+
def _get_tool_extension(self, func_name: str) -> str | None:
|
|
508
|
+
"""Return extension group name if tool belongs to one, else None"""
|
|
509
|
+
for group, tools in self._extensions_registry.items():
|
|
510
|
+
if func_name in tools:
|
|
511
|
+
return group
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
def _mcp_tools_call(
|
|
515
|
+
self, name: str, arguments: dict | None = None, _meta: dict | None = None
|
|
516
|
+
) -> dict:
|
|
517
|
+
"""MCP tools/call method"""
|
|
518
|
+
# Check if tool requires an extension that isn't enabled
|
|
519
|
+
enabled = getattr(self._enabled_extensions, "data", set())
|
|
520
|
+
tool_group = self._get_tool_extension(name)
|
|
521
|
+
if tool_group and tool_group not in enabled:
|
|
522
|
+
return {
|
|
523
|
+
"content": [
|
|
524
|
+
{
|
|
525
|
+
"type": "text",
|
|
526
|
+
"text": f"Tool '{name}' requires extension '{tool_group}'. Enable with ?ext={tool_group}",
|
|
527
|
+
}
|
|
528
|
+
],
|
|
529
|
+
"isError": True,
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
# Register request for cancellation tracking
|
|
533
|
+
request_id = get_current_request_id()
|
|
534
|
+
if request_id is not None:
|
|
535
|
+
register_pending_request(request_id)
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
# Wrap tool call in JSON-RPC request
|
|
539
|
+
tool_response = self.tools.dispatch(
|
|
540
|
+
{
|
|
541
|
+
"jsonrpc": "2.0",
|
|
542
|
+
"method": name,
|
|
543
|
+
"params": arguments,
|
|
544
|
+
"id": None,
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Check for error response
|
|
549
|
+
if tool_response and "error" in tool_response:
|
|
550
|
+
error = tool_response["error"]
|
|
551
|
+
return {
|
|
552
|
+
"content": [
|
|
553
|
+
{"type": "text", "text": error.get("message", "Unknown error")}
|
|
554
|
+
],
|
|
555
|
+
"isError": True,
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
result = tool_response.get("result") if tool_response else None
|
|
559
|
+
return {
|
|
560
|
+
"content": [{"type": "text", "text": json.dumps(result, indent=2)}],
|
|
561
|
+
"structuredContent": result
|
|
562
|
+
if isinstance(result, dict)
|
|
563
|
+
else {"result": result},
|
|
564
|
+
"isError": False,
|
|
565
|
+
}
|
|
566
|
+
finally:
|
|
567
|
+
if request_id is not None:
|
|
568
|
+
unregister_pending_request(request_id)
|
|
569
|
+
|
|
570
|
+
def _mcp_notifications_cancelled(
|
|
571
|
+
self, requestId: int | str, reason: str | None = None
|
|
572
|
+
) -> None:
|
|
573
|
+
"""MCP notifications/cancelled - cancel an in-flight request"""
|
|
574
|
+
if cancel_request(requestId):
|
|
575
|
+
print(f"[MCP] Cancelled request {requestId}: {reason or 'no reason'}")
|
|
576
|
+
# Notifications don't return a response
|
|
577
|
+
|
|
578
|
+
def _mcp_resources_list(self, _meta: dict | None = None) -> dict:
|
|
579
|
+
"""MCP resources/list method - returns static resources only (no URI parameters)"""
|
|
580
|
+
resources = []
|
|
581
|
+
for func_name, func in self.resources.methods.items():
|
|
582
|
+
uri: str = getattr(func, "__resource_uri__")
|
|
583
|
+
|
|
584
|
+
# Skip templates (resources with parameters like {addr})
|
|
585
|
+
if "{" in uri:
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
resources.append(
|
|
589
|
+
{
|
|
590
|
+
"uri": uri,
|
|
591
|
+
"name": func_name,
|
|
592
|
+
"description": (func.__doc__ or f"Read {uri}").strip(),
|
|
593
|
+
"mimeType": "application/json",
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
return {"resources": resources}
|
|
598
|
+
|
|
599
|
+
def _mcp_resource_templates_list(self, _meta: dict | None = None) -> dict:
|
|
600
|
+
"""MCP resources/templates/list method - returns parameterized resource templates"""
|
|
601
|
+
templates = []
|
|
602
|
+
for func_name, func in self.resources.methods.items():
|
|
603
|
+
uri: str = getattr(func, "__resource_uri__")
|
|
604
|
+
|
|
605
|
+
# Only include templates (resources with parameters like {addr})
|
|
606
|
+
if "{" not in uri:
|
|
607
|
+
continue
|
|
608
|
+
|
|
609
|
+
templates.append(
|
|
610
|
+
{
|
|
611
|
+
"uriTemplate": uri,
|
|
612
|
+
"name": func_name,
|
|
613
|
+
"description": (func.__doc__ or f"Read {uri}").strip(),
|
|
614
|
+
"mimeType": "application/json",
|
|
615
|
+
}
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
return {"resourceTemplates": templates}
|
|
619
|
+
|
|
620
|
+
def _mcp_resources_read(self, uri: str, _meta: dict | None = None) -> dict:
|
|
621
|
+
"""MCP resources/read method"""
|
|
622
|
+
|
|
623
|
+
# Try to match URI against all registered resource patterns
|
|
624
|
+
for func_name, func in self.resources.methods.items():
|
|
625
|
+
pattern: str = getattr(func, "__resource_uri__")
|
|
626
|
+
|
|
627
|
+
# Convert pattern to regex, replacing {param} with named capture groups
|
|
628
|
+
regex_pattern = re.sub(r"\{(\w+)\}", r"(?P<\1>[^/]+)", pattern)
|
|
629
|
+
regex_pattern = f"^{regex_pattern}$"
|
|
630
|
+
|
|
631
|
+
match = re.match(regex_pattern, uri)
|
|
632
|
+
if match:
|
|
633
|
+
# Found matching resource - call it via JSON-RPC
|
|
634
|
+
params = list(match.groupdict().values())
|
|
635
|
+
|
|
636
|
+
tool_response = self.resources.dispatch(
|
|
637
|
+
{
|
|
638
|
+
"jsonrpc": "2.0",
|
|
639
|
+
"method": func_name,
|
|
640
|
+
"params": params,
|
|
641
|
+
"id": None,
|
|
642
|
+
}
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
if tool_response and "error" in tool_response:
|
|
646
|
+
error = tool_response["error"]
|
|
647
|
+
return {
|
|
648
|
+
"contents": [
|
|
649
|
+
{
|
|
650
|
+
"uri": uri,
|
|
651
|
+
"mimeType": "application/json",
|
|
652
|
+
"text": json.dumps(
|
|
653
|
+
{"error": error.get("message", "Unknown error")},
|
|
654
|
+
indent=2,
|
|
655
|
+
),
|
|
656
|
+
}
|
|
657
|
+
],
|
|
658
|
+
"isError": True,
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
result = tool_response.get("result") if tool_response else None
|
|
662
|
+
return {
|
|
663
|
+
"contents": [
|
|
664
|
+
{
|
|
665
|
+
"uri": uri,
|
|
666
|
+
"mimeType": "application/json",
|
|
667
|
+
"text": json.dumps(result, indent=2),
|
|
668
|
+
}
|
|
669
|
+
]
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
# No matching resource found
|
|
673
|
+
available: list[str] = [
|
|
674
|
+
getattr(f, "__resource_uri__") for f in self.resources.methods.values()
|
|
675
|
+
]
|
|
676
|
+
return {
|
|
677
|
+
"contents": [
|
|
678
|
+
{
|
|
679
|
+
"uri": uri,
|
|
680
|
+
"mimeType": "application/json",
|
|
681
|
+
"text": json.dumps(
|
|
682
|
+
{
|
|
683
|
+
"error": f"Resource not found: {uri}",
|
|
684
|
+
"available_patterns": available,
|
|
685
|
+
},
|
|
686
|
+
indent=2,
|
|
687
|
+
),
|
|
688
|
+
}
|
|
689
|
+
],
|
|
690
|
+
"isError": True,
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
def _mcp_prompts_list(self, _meta: dict | None = None) -> dict:
|
|
694
|
+
"""MCP prompts/list method"""
|
|
695
|
+
return {
|
|
696
|
+
"prompts": [
|
|
697
|
+
self._generate_prompt_schema(func_name, func)
|
|
698
|
+
for func_name, func in self.prompts.methods.items()
|
|
699
|
+
],
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
def _mcp_prompts_get(
|
|
703
|
+
self, name: str, arguments: dict | None = None, _meta: dict | None = None
|
|
704
|
+
) -> dict:
|
|
705
|
+
"""MCP prompts/get method"""
|
|
706
|
+
# Dispatch to prompts registry
|
|
707
|
+
prompt_response = self.prompts.dispatch(
|
|
708
|
+
{
|
|
709
|
+
"jsonrpc": "2.0",
|
|
710
|
+
"method": name,
|
|
711
|
+
"params": arguments,
|
|
712
|
+
"id": None,
|
|
713
|
+
}
|
|
714
|
+
)
|
|
715
|
+
assert prompt_response is not None, "Only notification requests return None"
|
|
716
|
+
|
|
717
|
+
# Check for error response
|
|
718
|
+
if "error" in prompt_response:
|
|
719
|
+
error = prompt_response["error"]
|
|
720
|
+
raise JsonRpcException(error["code"], error["message"], error.get("data"))
|
|
721
|
+
|
|
722
|
+
result = prompt_response.get("result")
|
|
723
|
+
|
|
724
|
+
# Pass through list of messages directly
|
|
725
|
+
if isinstance(result, list):
|
|
726
|
+
return {"messages": result}
|
|
727
|
+
|
|
728
|
+
# Convert non-string results to JSON
|
|
729
|
+
if not isinstance(result, str):
|
|
730
|
+
result = json.dumps(result, indent=2)
|
|
731
|
+
return {
|
|
732
|
+
"messages": [
|
|
733
|
+
{
|
|
734
|
+
"role": "user",
|
|
735
|
+
"content": {"type": "text", "text": result},
|
|
736
|
+
},
|
|
737
|
+
],
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
def _generate_prompt_schema(self, func_name: str, func: Callable) -> dict:
|
|
741
|
+
"""Generate MCP prompt schema from a function"""
|
|
742
|
+
hints = get_type_hints(func, include_extras=True)
|
|
743
|
+
hints.pop("return", None)
|
|
744
|
+
sig = inspect.signature(func)
|
|
745
|
+
|
|
746
|
+
# Build arguments list (PromptArgument format)
|
|
747
|
+
arguments = []
|
|
748
|
+
for param_name, param_type in hints.items():
|
|
749
|
+
arg: dict[str, Any] = {"name": param_name}
|
|
750
|
+
|
|
751
|
+
# Extract description from Annotated
|
|
752
|
+
origin = get_origin(param_type)
|
|
753
|
+
if origin is Annotated:
|
|
754
|
+
args = get_args(param_type)
|
|
755
|
+
arg["description"] = str(args[-1])
|
|
756
|
+
|
|
757
|
+
# Check if required (no default value)
|
|
758
|
+
param = sig.parameters.get(param_name)
|
|
759
|
+
if not param or param.default is inspect.Parameter.empty:
|
|
760
|
+
arg["required"] = True
|
|
761
|
+
|
|
762
|
+
arguments.append(arg)
|
|
763
|
+
|
|
764
|
+
schema: dict[str, Any] = {
|
|
765
|
+
"name": func_name,
|
|
766
|
+
"description": (func.__doc__ or f"Prompt {func_name}").strip(),
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if arguments:
|
|
770
|
+
schema["arguments"] = arguments
|
|
771
|
+
|
|
772
|
+
return schema
|
|
773
|
+
|
|
774
|
+
def _type_to_json_schema(self, py_type: Any) -> dict:
|
|
775
|
+
"""Convert Python type hint to JSON schema object"""
|
|
776
|
+
origin = get_origin(py_type)
|
|
777
|
+
# Annotated[T, "description"]
|
|
778
|
+
if origin is Annotated:
|
|
779
|
+
args = get_args(py_type)
|
|
780
|
+
return {
|
|
781
|
+
**self._type_to_json_schema(args[0]),
|
|
782
|
+
"description": str(args[-1]),
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
# NotRequired[T]
|
|
786
|
+
if origin is NotRequired:
|
|
787
|
+
return self._type_to_json_schema(get_args(py_type)[0])
|
|
788
|
+
|
|
789
|
+
# Union[Ts..], Optional[T] and T1 | T2
|
|
790
|
+
if origin in (Union, UnionType):
|
|
791
|
+
return {"anyOf": [self._type_to_json_schema(t) for t in get_args(py_type)]}
|
|
792
|
+
|
|
793
|
+
# list[T]
|
|
794
|
+
if origin is list:
|
|
795
|
+
return {
|
|
796
|
+
"type": "array",
|
|
797
|
+
"items": self._type_to_json_schema(get_args(py_type)[0]),
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
# dict[str, T]
|
|
801
|
+
if origin is dict:
|
|
802
|
+
return {
|
|
803
|
+
"type": "object",
|
|
804
|
+
"additionalProperties": self._type_to_json_schema(get_args(py_type)[1]),
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
# TypedDict
|
|
808
|
+
if is_typeddict(py_type):
|
|
809
|
+
return self._typed_dict_to_schema(py_type)
|
|
810
|
+
|
|
811
|
+
# Primitives
|
|
812
|
+
return {
|
|
813
|
+
"type": {
|
|
814
|
+
int: "integer",
|
|
815
|
+
float: "number",
|
|
816
|
+
str: "string",
|
|
817
|
+
bool: "boolean",
|
|
818
|
+
list: "array",
|
|
819
|
+
dict: "object",
|
|
820
|
+
type(None): "null",
|
|
821
|
+
}.get(py_type, "object"),
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
def _typed_dict_to_schema(self, typed_dict_class) -> dict:
|
|
825
|
+
"""Convert TypedDict to JSON schema"""
|
|
826
|
+
hints = get_type_hints(typed_dict_class, include_extras=True)
|
|
827
|
+
required_keys = getattr(
|
|
828
|
+
typed_dict_class, "__required_keys__", set(hints.keys())
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
return {
|
|
832
|
+
"type": "object",
|
|
833
|
+
"properties": {
|
|
834
|
+
field_name: self._type_to_json_schema(field_type)
|
|
835
|
+
for field_name, field_type in hints.items()
|
|
836
|
+
},
|
|
837
|
+
"required": [key for key in hints.keys() if key in required_keys],
|
|
838
|
+
"additionalProperties": False,
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
def _generate_tool_schema(self, func_name: str, func: Callable) -> dict:
|
|
842
|
+
"""Generate MCP tool schema from a function"""
|
|
843
|
+
hints = get_type_hints(func, include_extras=True)
|
|
844
|
+
return_type = hints.pop("return", None)
|
|
845
|
+
sig = inspect.signature(func)
|
|
846
|
+
|
|
847
|
+
# Build parameter schema
|
|
848
|
+
properties = {}
|
|
849
|
+
required = []
|
|
850
|
+
|
|
851
|
+
for param_name, param_type in hints.items():
|
|
852
|
+
properties[param_name] = self._type_to_json_schema(param_type)
|
|
853
|
+
|
|
854
|
+
# Add to required if no default value
|
|
855
|
+
param = sig.parameters.get(param_name)
|
|
856
|
+
if not param or param.default is inspect.Parameter.empty:
|
|
857
|
+
required.append(param_name)
|
|
858
|
+
|
|
859
|
+
schema: dict[str, Any] = {
|
|
860
|
+
"name": func_name,
|
|
861
|
+
"description": (func.__doc__ or f"Call {func_name}").strip(),
|
|
862
|
+
"inputSchema": {
|
|
863
|
+
"type": "object",
|
|
864
|
+
"properties": properties,
|
|
865
|
+
"required": required,
|
|
866
|
+
},
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
# Add outputSchema if return type exists and is not None
|
|
870
|
+
if return_type and return_type is not type(None):
|
|
871
|
+
return_schema = self._type_to_json_schema(return_type)
|
|
872
|
+
|
|
873
|
+
# Wrap non-object returns in a "result" property
|
|
874
|
+
if return_schema.get("type") != "object":
|
|
875
|
+
return_schema = {
|
|
876
|
+
"type": "object",
|
|
877
|
+
"properties": {"result": return_schema},
|
|
878
|
+
"required": ["result"],
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
schema["outputSchema"] = return_schema
|
|
882
|
+
|
|
883
|
+
return schema
|