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.
Files changed (45) hide show
  1. ida_pro_mcp/__init__.py +0 -0
  2. ida_pro_mcp/__main__.py +6 -0
  3. ida_pro_mcp/ida_mcp/__init__.py +68 -0
  4. ida_pro_mcp/ida_mcp/api_analysis.py +1296 -0
  5. ida_pro_mcp/ida_mcp/api_core.py +337 -0
  6. ida_pro_mcp/ida_mcp/api_debug.py +617 -0
  7. ida_pro_mcp/ida_mcp/api_memory.py +304 -0
  8. ida_pro_mcp/ida_mcp/api_modify.py +406 -0
  9. ida_pro_mcp/ida_mcp/api_python.py +179 -0
  10. ida_pro_mcp/ida_mcp/api_resources.py +295 -0
  11. ida_pro_mcp/ida_mcp/api_stack.py +167 -0
  12. ida_pro_mcp/ida_mcp/api_types.py +480 -0
  13. ida_pro_mcp/ida_mcp/auth.py +166 -0
  14. ida_pro_mcp/ida_mcp/cache.py +232 -0
  15. ida_pro_mcp/ida_mcp/config.py +228 -0
  16. ida_pro_mcp/ida_mcp/framework.py +547 -0
  17. ida_pro_mcp/ida_mcp/http.py +859 -0
  18. ida_pro_mcp/ida_mcp/port_utils.py +104 -0
  19. ida_pro_mcp/ida_mcp/rpc.py +187 -0
  20. ida_pro_mcp/ida_mcp/server_manager.py +339 -0
  21. ida_pro_mcp/ida_mcp/sync.py +233 -0
  22. ida_pro_mcp/ida_mcp/tests/__init__.py +14 -0
  23. ida_pro_mcp/ida_mcp/tests/test_api_analysis.py +336 -0
  24. ida_pro_mcp/ida_mcp/tests/test_api_core.py +237 -0
  25. ida_pro_mcp/ida_mcp/tests/test_api_memory.py +207 -0
  26. ida_pro_mcp/ida_mcp/tests/test_api_modify.py +123 -0
  27. ida_pro_mcp/ida_mcp/tests/test_api_resources.py +199 -0
  28. ida_pro_mcp/ida_mcp/tests/test_api_stack.py +77 -0
  29. ida_pro_mcp/ida_mcp/tests/test_api_types.py +249 -0
  30. ida_pro_mcp/ida_mcp/ui.py +357 -0
  31. ida_pro_mcp/ida_mcp/utils.py +1186 -0
  32. ida_pro_mcp/ida_mcp/zeromcp/__init__.py +5 -0
  33. ida_pro_mcp/ida_mcp/zeromcp/jsonrpc.py +384 -0
  34. ida_pro_mcp/ida_mcp/zeromcp/mcp.py +883 -0
  35. ida_pro_mcp/ida_mcp.py +186 -0
  36. ida_pro_mcp/idalib_server.py +354 -0
  37. ida_pro_mcp/idalib_session_manager.py +259 -0
  38. ida_pro_mcp/server.py +1060 -0
  39. ida_pro_mcp/test.py +170 -0
  40. ida_pro_mcp_xjoker-1.0.1.dist-info/METADATA +405 -0
  41. ida_pro_mcp_xjoker-1.0.1.dist-info/RECORD +45 -0
  42. ida_pro_mcp_xjoker-1.0.1.dist-info/WHEEL +5 -0
  43. ida_pro_mcp_xjoker-1.0.1.dist-info/entry_points.txt +4 -0
  44. ida_pro_mcp_xjoker-1.0.1.dist-info/licenses/LICENSE +21 -0
  45. 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