mcp-proxy-adapter 2.1.13__py3-none-any.whl → 2.1.15__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.
@@ -0,0 +1,697 @@
1
+ """
2
+ Main adapter module for MCPProxy.
3
+
4
+ This module contains the MCPProxyAdapter class, which provides
5
+ integration of Command Registry with MCPProxy for working with AI model tools.
6
+ """
7
+ import logging
8
+ import json
9
+ from typing import Dict, Any, List, Optional, Union, Callable, Protocol, Type
10
+ from fastapi import FastAPI, APIRouter, Request, Response, HTTPException, Depends
11
+ from pydantic import BaseModel, Field
12
+
13
+ try:
14
+ # Import when package is installed
15
+ from mcp_proxy_adapter.models import (
16
+ JsonRpcRequest,
17
+ JsonRpcResponse,
18
+ CommandInfo,
19
+ MCPProxyTool,
20
+ MCPProxyConfig
21
+ )
22
+ from mcp_proxy_adapter.schema import SchemaOptimizer
23
+ except ImportError:
24
+ # Import during local development
25
+ try:
26
+ from .models import (
27
+ JsonRpcRequest,
28
+ JsonRpcResponse,
29
+ CommandInfo,
30
+ MCPProxyTool,
31
+ MCPProxyConfig
32
+ )
33
+ from .schema import SchemaOptimizer
34
+ except ImportError:
35
+ # Direct import for tests
36
+ from src.models import (
37
+ JsonRpcRequest,
38
+ JsonRpcResponse,
39
+ CommandInfo,
40
+ MCPProxyTool,
41
+ MCPProxyConfig
42
+ )
43
+ from src.schema import SchemaOptimizer
44
+
45
+ # Initialize logger with default settings
46
+ logger = logging.getLogger("mcp_proxy_adapter")
47
+
48
+ def configure_logger(parent_logger=None):
49
+ """
50
+ Configures the adapter logger with the ability to use a parent logger.
51
+
52
+ Args:
53
+ parent_logger: Parent project logger, if available
54
+
55
+ Returns:
56
+ logging.Logger: Configured adapter logger
57
+ """
58
+ global logger
59
+ if parent_logger:
60
+ logger = parent_logger.getChild('mcp_proxy_adapter')
61
+ else:
62
+ logger = logging.getLogger("mcp_proxy_adapter")
63
+ return logger
64
+
65
+ class CommandRegistry(Protocol):
66
+ """Protocol for CommandRegistry."""
67
+
68
+ @property
69
+ def dispatcher(self) -> Any:
70
+ """Get the command dispatcher."""
71
+ ...
72
+
73
+ def get_commands_info(self) -> Dict[str, Dict[str, Any]]:
74
+ """Get information about all registered commands."""
75
+ ...
76
+
77
+ def add_generator(self, generator: Any) -> None:
78
+ """Add an API generator."""
79
+ ...
80
+
81
+ class OpenApiGenerator(Protocol):
82
+ """Protocol for OpenAPI schema generator."""
83
+
84
+ def generate_schema(self) -> Dict[str, Any]:
85
+ """Generate OpenAPI schema."""
86
+ ...
87
+
88
+ def set_dispatcher(self, dispatcher: Any) -> None:
89
+ """Set the command dispatcher."""
90
+ ...
91
+
92
+ class MCPProxyAdapter:
93
+ """
94
+ Adapter for integrating Command Registry with MCPProxy.
95
+
96
+ This adapter creates a hybrid API that supports both REST and JSON-RPC
97
+ requests, and optimizes it for use with MCPProxy.
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ registry: CommandRegistry,
103
+ cmd_endpoint: str = "/cmd", # Added ability to specify cmd_endpoint
104
+ include_schema: bool = True,
105
+ optimize_schema: bool = True,
106
+ tool_name_prefix: str = "mcp_"
107
+ ):
108
+ """
109
+ Initializes the adapter for MCPProxy.
110
+
111
+ Args:
112
+ registry: CommandRegistry instance
113
+ cmd_endpoint: Path for universal JSON-RPC endpoint
114
+ include_schema: Whether to include endpoint for getting OpenAPI schema
115
+ optimize_schema: Whether to optimize schema for AI models
116
+ tool_name_prefix: Prefix for tool names
117
+ """
118
+ self.registry = registry
119
+ self.cmd_endpoint = cmd_endpoint # Use the provided parameter
120
+ self.include_schema = include_schema
121
+ self.optimize_schema = optimize_schema
122
+ self.tool_name_prefix = tool_name_prefix
123
+ self.router = APIRouter()
124
+
125
+ # Schema optimizer
126
+ self.schema_optimizer = SchemaOptimizer()
127
+
128
+ # Route configuration
129
+ self._generate_router()
130
+
131
+ # OpenAPI generator setup, if provided
132
+ try:
133
+ # Import here to avoid requiring the dependency if not used
134
+ from command_registry.generators.openapi_generator import OpenApiGenerator
135
+ self.openapi_generator = OpenApiGenerator(
136
+ title="Command Registry API",
137
+ description="API for executing commands through MCPProxy",
138
+ version="1.0.0"
139
+ )
140
+ self.registry.add_generator(self.openapi_generator)
141
+ except ImportError:
142
+ logger.info("OpenApiGenerator not found, schema generation will be limited")
143
+ self.openapi_generator = None
144
+
145
+ def _validate_param_types(self, command: str, params: Dict[str, Any]) -> List[str]:
146
+ """
147
+ Validates parameter types and returns validation errors.
148
+
149
+ Args:
150
+ command: Command name
151
+ params: Command parameters
152
+
153
+ Returns:
154
+ List[str]: List of validation errors
155
+ """
156
+ errors = []
157
+ command_info = self.registry.dispatcher.get_command_info(command)
158
+
159
+ if not command_info or "params" not in command_info:
160
+ return errors
161
+
162
+ for param_name, param_info in command_info["params"].items():
163
+ if param_name not in params:
164
+ continue
165
+
166
+ param_value = params[param_name]
167
+ param_type = param_info.get("type", "string")
168
+
169
+ # Check basic types
170
+ if param_type == "string" and not isinstance(param_value, str):
171
+ errors.append(f"Parameter '{param_name}' must be a string")
172
+ elif param_type == "integer" and not isinstance(param_value, int):
173
+ errors.append(f"Parameter '{param_name}' must be an integer")
174
+ elif param_type == "number" and not isinstance(param_value, (int, float)):
175
+ errors.append(f"Parameter '{param_name}' must be a number")
176
+ elif param_type == "boolean" and not isinstance(param_value, bool):
177
+ errors.append(f"Parameter '{param_name}' must be a boolean")
178
+ elif param_type == "array" and not isinstance(param_value, list):
179
+ errors.append(f"Parameter '{param_name}' must be an array")
180
+ elif param_type == "object" and not isinstance(param_value, dict):
181
+ errors.append(f"Parameter '{param_name}' must be an object")
182
+
183
+ return errors
184
+
185
+ def _generate_router(self) -> None:
186
+ """Generates FastAPI routes for the adapter."""
187
+ # Universal endpoint for executing commands via JSON-RPC
188
+ @self.router.post(self.cmd_endpoint, response_model=JsonRpcResponse)
189
+ async def execute_command(request: JsonRpcRequest):
190
+ """Executes a command via JSON-RPC protocol."""
191
+ try:
192
+ # Check if command exists
193
+ if request.method not in self.registry.dispatcher.get_valid_commands():
194
+ logger.warning(f"Attempt to call non-existent command: {request.method}")
195
+ return JsonRpcResponse(
196
+ jsonrpc="2.0",
197
+ error={
198
+ "code": -32601,
199
+ "message": f"Command '{request.method}' not found"
200
+ },
201
+ id=request.id
202
+ )
203
+
204
+ # Check for required parameters
205
+ command_info = self.registry.dispatcher.get_command_info(request.method)
206
+ if command_info and "params" in command_info:
207
+ missing_params = []
208
+ for param_name, param_info in command_info["params"].items():
209
+ if param_info.get("required", False) and param_name not in request.params:
210
+ missing_params.append(param_name)
211
+
212
+ if missing_params:
213
+ logger.warning(f"Missing required parameters for command {request.method}: {missing_params}")
214
+ return JsonRpcResponse(
215
+ jsonrpc="2.0",
216
+ error={
217
+ "code": -32602,
218
+ "message": f"Missing required parameters: {', '.join(missing_params)}"
219
+ },
220
+ id=request.id
221
+ )
222
+
223
+ # Check parameter types
224
+ type_errors = self._validate_param_types(request.method, request.params)
225
+ if type_errors:
226
+ logger.warning(f"Parameter type errors for command {request.method}: {type_errors}")
227
+ return JsonRpcResponse(
228
+ jsonrpc="2.0",
229
+ error={
230
+ "code": -32602,
231
+ "message": f"Invalid parameter types: {', '.join(type_errors)}"
232
+ },
233
+ id=request.id
234
+ )
235
+
236
+ # Execute the command
237
+ logger.debug(f"Executing command {request.method} with parameters {request.params}")
238
+ try:
239
+ result = await self.registry.dispatcher.execute(
240
+ request.method,
241
+ **request.params
242
+ )
243
+
244
+ # Return the result
245
+ return JsonRpcResponse(
246
+ jsonrpc="2.0",
247
+ result=result,
248
+ id=request.id
249
+ )
250
+ except TypeError as e:
251
+ # Type error in arguments or unknown argument
252
+ logger.error(f"Error in command arguments {request.method}: {str(e)}")
253
+ return JsonRpcResponse(
254
+ jsonrpc="2.0",
255
+ error={
256
+ "code": -32602,
257
+ "message": f"Invalid parameters: {str(e)}"
258
+ },
259
+ id=request.id
260
+ )
261
+ except Exception as e:
262
+ # Other errors during command execution
263
+ logger.exception(f"Error executing command {request.method}: {str(e)}")
264
+ return JsonRpcResponse(
265
+ jsonrpc="2.0",
266
+ error={
267
+ "code": -32603,
268
+ "message": f"Internal error: {str(e)}"
269
+ },
270
+ id=request.id
271
+ )
272
+ except Exception as e:
273
+ # Handle unexpected errors
274
+ logger.exception(f"Unexpected error processing request: {str(e)}")
275
+ return JsonRpcResponse(
276
+ jsonrpc="2.0",
277
+ error={
278
+ "code": -32603,
279
+ "message": f"Internal server error: {str(e)}"
280
+ },
281
+ id=request.id if hasattr(request, 'id') else None
282
+ )
283
+
284
+ # Add endpoint for getting OpenAPI schema
285
+ if self.include_schema:
286
+ @self.router.get("/openapi.json")
287
+ async def get_openapi_schema():
288
+ """Returns optimized OpenAPI schema."""
289
+ if self.openapi_generator:
290
+ schema = self.openapi_generator.generate_schema()
291
+ else:
292
+ schema = self._generate_basic_schema()
293
+
294
+ # Optimize schema for MCP Proxy
295
+ if self.optimize_schema:
296
+ schema = self.schema_optimizer.optimize(
297
+ schema,
298
+ self.cmd_endpoint,
299
+ self.registry.get_commands_info()
300
+ )
301
+
302
+ return schema
303
+
304
+ def _generate_basic_schema(self) -> Dict[str, Any]:
305
+ """
306
+ Generates a basic OpenAPI schema when OpenApiGenerator is not found.
307
+
308
+ Returns:
309
+ Dict[str, Any]: Basic OpenAPI schema
310
+ """
311
+ schema = {
312
+ "openapi": "3.0.2",
313
+ "info": {
314
+ "title": "Command Registry API",
315
+ "description": "API for executing commands through MCPProxy",
316
+ "version": "1.0.0"
317
+ },
318
+ "paths": {
319
+ self.cmd_endpoint: {
320
+ "post": {
321
+ "summary": "Execute command via JSON-RPC",
322
+ "description": "Universal endpoint for executing commands",
323
+ "operationId": "execute_command",
324
+ "requestBody": {
325
+ "content": {
326
+ "application/json": {
327
+ "schema": {
328
+ "$ref": "#/components/schemas/JsonRpcRequest"
329
+ }
330
+ }
331
+ },
332
+ "required": True
333
+ },
334
+ "responses": {
335
+ "200": {
336
+ "description": "Command executed successfully",
337
+ "content": {
338
+ "application/json": {
339
+ "schema": {
340
+ "$ref": "#/components/schemas/JsonRpcResponse"
341
+ }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+ }
348
+ },
349
+ "components": {
350
+ "schemas": {
351
+ "JsonRpcRequest": {
352
+ "type": "object",
353
+ "properties": {
354
+ "jsonrpc": {
355
+ "type": "string",
356
+ "description": "JSON-RPC version",
357
+ "default": "2.0"
358
+ },
359
+ "method": {
360
+ "type": "string",
361
+ "description": "Method name for execution"
362
+ },
363
+ "params": {
364
+ "type": "object",
365
+ "description": "Method parameters",
366
+ "additionalProperties": True
367
+ },
368
+ "id": {
369
+ "oneOf": [
370
+ {"type": "string"},
371
+ {"type": "integer"},
372
+ {"type": "null"}
373
+ ],
374
+ "description": "Request identifier"
375
+ }
376
+ },
377
+ "required": ["jsonrpc", "method"]
378
+ },
379
+ "JsonRpcResponse": {
380
+ "type": "object",
381
+ "properties": {
382
+ "jsonrpc": {
383
+ "type": "string",
384
+ "description": "JSON-RPC version",
385
+ "default": "2.0"
386
+ },
387
+ "result": {
388
+ "description": "Command execution result",
389
+ "nullable": True
390
+ },
391
+ "error": {
392
+ "type": "object",
393
+ "description": "Command execution error information",
394
+ "nullable": True,
395
+ "properties": {
396
+ "code": {
397
+ "type": "integer",
398
+ "description": "Error code"
399
+ },
400
+ "message": {
401
+ "type": "string",
402
+ "description": "Error message"
403
+ },
404
+ "data": {
405
+ "description": "Additional error data",
406
+ "nullable": True
407
+ }
408
+ }
409
+ },
410
+ "id": {
411
+ "oneOf": [
412
+ {"type": "string"},
413
+ {"type": "integer"},
414
+ {"type": "null"}
415
+ ],
416
+ "description": "Request identifier"
417
+ }
418
+ },
419
+ "required": ["jsonrpc"]
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ return schema
426
+
427
+ def _register_mcp_cmd_endpoint(self, app: FastAPI) -> None:
428
+ """
429
+ Registers /cmd endpoint compatible with MCP Proxy format.
430
+
431
+ Args:
432
+ app: FastAPI application instance
433
+ """
434
+ @app.post("/cmd")
435
+ async def mcp_cmd_endpoint(request: Request):
436
+ """Executes a command in MCP Proxy format or JSON-RPC."""
437
+ try:
438
+ # Get data from request
439
+ request_data = await request.json()
440
+
441
+ # Detailed logging of the entire request
442
+ logger.info(f"RECEIVED REQUEST TO /cmd: {json.dumps(request_data)}")
443
+
444
+ # Check request format
445
+ if "command" in request_data:
446
+ # MCP Proxy format: {"command": "...", "params": {...}}
447
+ command = request_data["command"]
448
+ params = request_data.get("params", {})
449
+
450
+ logger.debug(f"Received request to /cmd in MCP Proxy format: command={command}, params={params}")
451
+ elif "method" in request_data:
452
+ # JSON-RPC format: {"jsonrpc": "2.0", "method": "...", "params": {...}, "id": ...}
453
+ command = request_data["method"]
454
+ params = request_data.get("params", {})
455
+
456
+ logger.debug(f"Received request to /cmd in JSON-RPC format: method={command}, params={params}")
457
+ elif "params" in request_data:
458
+ # Implied command format: {"params": {...}}
459
+ params = request_data["params"]
460
+
461
+ # Check if params contains command, use it as command name
462
+ if "command" in params:
463
+ command = params.pop("command") # Use command from params and remove it from params
464
+ logger.info(f"Extracting command from params.command field: {command}")
465
+ # Check if params contains field that can be used as command name
466
+ elif "query" in params:
467
+ # Use query as command name or subcommand
468
+ query_value = params["query"]
469
+ logger.info(f"Extracting command from query field: {query_value}")
470
+
471
+ # If query contains "/", split into parts
472
+ if isinstance(query_value, str) and "/" in query_value:
473
+ command_parts = query_value.split("/")
474
+ command = command_parts[0] # First part - command name
475
+
476
+ # Add remaining part back to params as subcommand
477
+ params["subcommand"] = "/".join(command_parts[1:])
478
+ else:
479
+ command = "execute" # Use fixed command
480
+ # Leave query in params as is
481
+ else:
482
+ # Use default command
483
+ command = "execute"
484
+
485
+ logger.info(f"Processing request with implied command: using command={command}, params={params}")
486
+ else:
487
+ # Unknown format - return error in response body
488
+ logger.warning(f"Received request with incorrect format: {json.dumps(request_data)}")
489
+ return {
490
+ "error": {
491
+ "code": 422,
492
+ "message": "Missing required fields",
493
+ "details": "Request requires 'command', 'method' or 'params' field"
494
+ }
495
+ }
496
+
497
+ # Check if command exists
498
+ if command not in self.registry.dispatcher.get_valid_commands():
499
+ logger.warning(f"Attempt to call non-existent command: {command}")
500
+ return {
501
+ "error": {
502
+ "code": 404,
503
+ "message": f"Unknown command: {command}",
504
+ "details": f"Command '{command}' not found in registry. Available commands: {', '.join(self.registry.dispatcher.get_valid_commands())}"
505
+ }
506
+ }
507
+
508
+ # Check for required parameters
509
+ command_info = self.registry.dispatcher.get_command_info(command)
510
+ if command_info and "params" in command_info:
511
+ missing_params = []
512
+ for param_name, param_info in command_info["params"].items():
513
+ if param_info.get("required", False) and param_name not in params:
514
+ missing_params.append(param_name)
515
+
516
+ if missing_params:
517
+ logger.warning(f"Missing required parameters for command {command}: {missing_params}")
518
+ return {
519
+ "error": {
520
+ "code": 400,
521
+ "message": f"Missing required parameters: {', '.join(missing_params)}",
522
+ "details": f"Command '{command}' requires following parameters: {', '.join(missing_params)}"
523
+ }
524
+ }
525
+
526
+ # Check parameter types
527
+ type_errors = self._validate_param_types(command, params)
528
+ if type_errors:
529
+ logger.warning(f"Parameter type errors for command {command}: {type_errors}")
530
+ return {
531
+ "error": {
532
+ "code": 400,
533
+ "message": f"Invalid parameter types: {', '.join(type_errors)}",
534
+ "details": "Check parameter types and try again"
535
+ }
536
+ }
537
+
538
+ # Execute the command
539
+ try:
540
+ result = await self.registry.dispatcher.execute(command, **params)
541
+
542
+ # Return result in MCP Proxy format
543
+ return {"result": result}
544
+
545
+ except Exception as e:
546
+ logger.error(f"Error executing command {command}: {str(e)}")
547
+ return {
548
+ "error": {
549
+ "code": 500,
550
+ "message": str(e),
551
+ "details": f"Error executing command '{command}': {str(e)}"
552
+ }
553
+ }
554
+
555
+ except json.JSONDecodeError:
556
+ # JSON parsing error
557
+ logger.error("JSON parsing error from request")
558
+ return {
559
+ "error": {
560
+ "code": 400,
561
+ "message": "Invalid JSON format",
562
+ "details": "Request contains incorrect JSON. Check request syntax."
563
+ }
564
+ }
565
+ except Exception as e:
566
+ logger.error(f"Error processing request to /cmd: {str(e)}")
567
+ return {
568
+ "error": {
569
+ "code": 500,
570
+ "message": "Internal server error",
571
+ "details": str(e)
572
+ }
573
+ }
574
+
575
+ def register_endpoints(self, app: FastAPI) -> None:
576
+ """
577
+ Registers adapter endpoints in FastAPI application.
578
+
579
+ Args:
580
+ app: FastAPI application instance
581
+ """
582
+ # IMPORTANT: first register /cmd endpoint for MCP Proxy compatibility
583
+ self._register_mcp_cmd_endpoint(app)
584
+
585
+ # Then integrate main JSON-RPC router into application
586
+ app.include_router(self.router)
587
+
588
+ # Add endpoint for getting list of commands
589
+ @app.get("/api/commands")
590
+ def get_commands():
591
+ """Returns list of available commands with their descriptions."""
592
+ commands_info = self.registry.get_commands_info()
593
+ return {
594
+ "commands": commands_info
595
+ }
596
+
597
+ def generate_mcp_proxy_config(self) -> MCPProxyConfig:
598
+ """
599
+ Generates MCP Proxy configuration based on registered commands.
600
+
601
+ Returns:
602
+ MCPProxyConfig: MCP Proxy configuration
603
+ """
604
+ tools = []
605
+ routes = []
606
+
607
+ # Get command information
608
+ commands_info = self.registry.get_commands_info()
609
+
610
+ # Create tools for each command
611
+ for cmd_name, cmd_info in commands_info.items():
612
+ # Create parameters schema for command
613
+ parameters = {
614
+ "type": "object",
615
+ "properties": {},
616
+ "required": []
617
+ }
618
+
619
+ # Add parameter properties
620
+ for param_name, param_info in cmd_info.get("params", {}).items():
621
+ # Parameter property
622
+ param_property = {
623
+ "type": param_info.get("type", "string"),
624
+ "description": param_info.get("description", "")
625
+ }
626
+
627
+ # Add additional properties if they exist
628
+ if "default" in param_info:
629
+ param_property["default"] = param_info["default"]
630
+
631
+ if "enum" in param_info:
632
+ param_property["enum"] = param_info["enum"]
633
+
634
+ # Add property to schema
635
+ parameters["properties"][param_name] = param_property
636
+
637
+ # If parameter is required, add to required list
638
+ if param_info.get("required", False):
639
+ parameters["required"].append(param_name)
640
+
641
+ # Create tool
642
+ tool = MCPProxyTool(
643
+ name=f"{self.tool_name_prefix}{cmd_name}",
644
+ description=cmd_info.get("description", ""),
645
+ parameters=parameters
646
+ )
647
+
648
+ tools.append(tool)
649
+
650
+ # Add route for tool
651
+ route = {
652
+ "tool_name": tool.name,
653
+ "endpoint": f"{self.cmd_endpoint}",
654
+ "method": "post",
655
+ "json_rpc": {
656
+ "method": cmd_name
657
+ }
658
+ }
659
+
660
+ routes.append(route)
661
+
662
+ # Create MCP Proxy configuration
663
+ config = MCPProxyConfig(
664
+ version="1.0",
665
+ tools=tools,
666
+ routes=routes
667
+ )
668
+
669
+ return config
670
+
671
+ def save_config_to_file(self, filename: str) -> None:
672
+ """
673
+ Saves MCP Proxy configuration to file.
674
+
675
+ Args:
676
+ filename: File name for saving
677
+ """
678
+ config = self.generate_mcp_proxy_config()
679
+
680
+ with open(filename, "w", encoding="utf-8") as f:
681
+ json.dump(config.model_dump(), f, ensure_ascii=False, indent=2)
682
+
683
+ logger.info(f"MCP Proxy configuration saved to file {filename}")
684
+
685
+ @classmethod
686
+ def from_registry(cls, registry, **kwargs):
687
+ """
688
+ Creates adapter instance from existing command registry.
689
+
690
+ Args:
691
+ registry: Command registry instance
692
+ **kwargs: Additional parameters for constructor
693
+
694
+ Returns:
695
+ MCPProxyAdapter: Configured adapter
696
+ """
697
+ return cls(registry, **kwargs)