agent-mcp 0.1.4__py3-none-any.whl → 0.1.5__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,616 @@
1
+ """
2
+ OpenAPI Protocol Support for AgentMCP
3
+ REST API discovery and standardization for agent tools
4
+
5
+ This module provides OpenAPI 3.0 specification generation and handling,
6
+ enabling agents to expose their capabilities through standard REST APIs.
7
+ """
8
+
9
+ import json
10
+ import uuid
11
+ import inspect
12
+ from datetime import datetime, timezone
13
+ from typing import Dict, Any, List, Optional, Callable, Union
14
+ from dataclasses import dataclass, asdict
15
+ import logging
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ @dataclass
20
+ class OpenAPIInfo:
21
+ """OpenAPI info object"""
22
+ title: str
23
+ version: str
24
+ description: str
25
+ contact: Dict[str, Any] = None
26
+ license: Dict[str, Any] = None
27
+
28
+ def __post_init__(self):
29
+ if self.contact is None:
30
+ self.contact = {}
31
+ if self.license is None:
32
+ self.license = {}
33
+
34
+ @dataclass
35
+ class OpenAPIPath:
36
+ """OpenAPI path object"""
37
+ path: str
38
+ method: str
39
+ operation_id: str
40
+ summary: str
41
+ description: str
42
+ parameters: List[Dict[str, Any]] = None
43
+ responses: Dict[str, Any] = None
44
+ tags: List[str] = None
45
+
46
+ def __post_init__(self):
47
+ if self.parameters is None:
48
+ self.parameters = []
49
+ if self.responses is None:
50
+ self.responses = {}
51
+ if self.tags is None:
52
+ self.tags = []
53
+
54
+ @dataclass
55
+ class OpenAPISchema:
56
+ """OpenAPI schema definition"""
57
+ type: str
58
+ properties: Dict[str, Any] = None
59
+ required: List[str] = None
60
+ items: Dict[str, Any] = None
61
+ description: str = ""
62
+
63
+ def __post_init__(self):
64
+ if self.properties is None:
65
+ self.properties = {}
66
+ if self.required is None:
67
+ self.required = []
68
+ if self.items is None:
69
+ self.items = {}
70
+
71
+ class OpenAPIGenerator:
72
+ """Generate OpenAPI 3.0 specifications from agent capabilities"""
73
+
74
+ def __init__(self, agent_name: str, agent_description: str = ""):
75
+ self.agent_name = agent_name
76
+ self.agent_description = agent_description
77
+ self.paths = []
78
+ self.schemas = {}
79
+ self.tags = []
80
+
81
+ def add_tool_from_function(
82
+ self,
83
+ func: Callable,
84
+ path: str = None,
85
+ method: str = "POST",
86
+ tags: List[str] = None
87
+ ):
88
+ """Add a tool as an OpenAPI path"""
89
+ try:
90
+ # Get function signature
91
+ sig = inspect.signature(func)
92
+ func_name = func.__name__
93
+
94
+ # Generate path if not provided
95
+ if not path:
96
+ path = f"/{func_name}"
97
+
98
+ # Extract parameters from function signature
99
+ parameters = []
100
+ required_params = []
101
+
102
+ for param_name, param in sig.parameters.items():
103
+ if param_name in ('self', 'ctx'):
104
+ continue
105
+
106
+ param_info = self._analyze_parameter(param_name, param)
107
+ parameters.append(param_info)
108
+
109
+ if param.default == inspect.Parameter.empty:
110
+ required_params.append(param_name)
111
+
112
+ # Create schema for request body
113
+ request_schema = None
114
+ if method.upper() in ['POST', 'PUT', 'PATCH'] and parameters:
115
+ request_schema = OpenAPISchema(
116
+ type="object",
117
+ properties={p["name"]: p["schema"] for p in parameters},
118
+ required=required_params,
119
+ description=f"Request body for {func_name}"
120
+ )
121
+
122
+ # Create response schemas
123
+ responses = {
124
+ "200": {
125
+ "description": f"Successful response from {func_name}",
126
+ "content": {
127
+ "application/json": {
128
+ "schema": self._create_response_schema(func)
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ # Add error responses
135
+ responses.update({
136
+ "400": {"description": "Bad request - invalid parameters"},
137
+ "500": {"description": "Internal server error"}
138
+ })
139
+
140
+ # Convert parameters to OpenAPI format
141
+ openapi_params = []
142
+ for param in parameters:
143
+ openapi_param = {
144
+ "name": param["name"],
145
+ "in": "body" if method.upper() in ['POST', 'PUT', 'PATCH'] else "query",
146
+ "description": param["description"],
147
+ "required": param["required"],
148
+ "schema": param["schema"]
149
+ }
150
+ openapi_params.append(openapi_param)
151
+
152
+ # Create path object
153
+ path_obj = OpenAPIPath(
154
+ path=path,
155
+ method=method.upper(),
156
+ operation_id=func_name,
157
+ summary=self._get_function_summary(func),
158
+ description=func.__doc__ or f"Execute {func_name}",
159
+ parameters=openapi_params,
160
+ responses=responses,
161
+ tags=tags or ["tools"]
162
+ )
163
+
164
+ self.paths.append(path_obj)
165
+
166
+ # Store schema for request body
167
+ if request_schema:
168
+ self.schemas[f"{func_name}Request"] = request_schema
169
+
170
+ logger.info(f"Added OpenAPI path: {method} {path}")
171
+
172
+ except Exception as e:
173
+ logger.error(f"Error adding tool {func.__name__} to OpenAPI: {e}")
174
+
175
+ def add_mcp_tools_as_paths(self, mcp_tools: Dict[str, Any]):
176
+ """Add MCP tools as OpenAPI paths"""
177
+ for tool_name, tool_info in mcp_tools.items():
178
+ # Create a wrapper function for the tool
179
+ def tool_wrapper(**kwargs):
180
+ return {"tool": tool_name, "args": kwargs}
181
+
182
+ # Add as OpenAPI path
183
+ self.add_tool_from_function(
184
+ func=tool_wrapper,
185
+ path=f"/tools/{tool_name}",
186
+ method="POST",
187
+ tags=["mcp", "tools"]
188
+ )
189
+
190
+ def add_agent_info_paths(self, agent_id: str, agent_info: Dict[str, Any]):
191
+ """Add standard agent information paths"""
192
+
193
+ # GET /agent/info
194
+ def get_agent_info():
195
+ return {
196
+ "agent_id": agent_id,
197
+ "name": agent_info.get("name", agent_id),
198
+ "description": agent_info.get("description", ""),
199
+ "framework": agent_info.get("framework", "unknown"),
200
+ "capabilities": agent_info.get("capabilities", []),
201
+ "status": "active"
202
+ }
203
+
204
+ self.add_tool_from_function(
205
+ func=get_agent_info,
206
+ path="/agent/info",
207
+ method="GET",
208
+ tags=["agent"]
209
+ )
210
+
211
+ # GET /agent/health
212
+ def get_agent_health():
213
+ return {
214
+ "status": "healthy",
215
+ "timestamp": datetime.now(timezone.utc).isoformat(),
216
+ "agent_id": agent_id
217
+ }
218
+
219
+ self.add_tool_from_function(
220
+ func=get_agent_health,
221
+ path="/agent/health",
222
+ method="GET",
223
+ tags=["agent", "health"]
224
+ )
225
+
226
+ # GET /agent/capabilities
227
+ def get_agent_capabilities():
228
+ return {
229
+ "agent_id": agent_id,
230
+ "capabilities": agent_info.get("capabilities", []),
231
+ "tools": list(self.schemas.keys())
232
+ }
233
+
234
+ self.add_tool_from_function(
235
+ func=get_agent_capabilities,
236
+ path="/agent/capabilities",
237
+ method="GET",
238
+ tags=["agent", "capabilities"]
239
+ )
240
+
241
+ def _analyze_parameter(self, param_name: str, param) -> Dict[str, Any]:
242
+ """Analyze a function parameter and create OpenAPI schema"""
243
+ param_type = "string"
244
+ param_format = None
245
+ param_enum = None
246
+ param_description = f"Parameter {param_name}"
247
+
248
+ # Try to get type from annotation
249
+ if param.annotation != inspect.Parameter.empty:
250
+ annotation_str = str(param.annotation)
251
+
252
+ if "int" in annotation_str.lower():
253
+ param_type = "integer"
254
+ elif "float" in annotation_str.lower() or "double" in annotation_str.lower():
255
+ param_type = "number"
256
+ elif "bool" in annotation_str.lower():
257
+ param_type = "boolean"
258
+ elif "list" in annotation_str.lower():
259
+ param_type = "array"
260
+ # Try to get item type
261
+ if hasattr(param.annotation, '__args__') and param.annotation.__args__:
262
+ item_type = str(param.annotation.__args__[0])
263
+ if "int" in item_type.lower():
264
+ param_items = {"type": "integer"}
265
+ elif "str" in item_type.lower():
266
+ param_items = {"type": "string"}
267
+ else:
268
+ param_items = {"type": "string"}
269
+ else:
270
+ param_items = {"type": "string"}
271
+ elif "dict" in annotation_str.lower():
272
+ param_type = "object"
273
+
274
+ # Check if parameter has default value
275
+ param_required = param.default == inspect.Parameter.empty
276
+
277
+ # Create schema
278
+ schema = {
279
+ "type": param_type,
280
+ "description": param_description
281
+ }
282
+
283
+ if param_format:
284
+ schema["format"] = param_format
285
+
286
+ if param_enum:
287
+ schema["enum"] = param_enum
288
+
289
+ if param_type == "array" and 'param_items' in locals():
290
+ schema["items"] = locals()['param_items']
291
+
292
+ return {
293
+ "name": param_name,
294
+ "description": param_description,
295
+ "required": param_required,
296
+ "type": param_type,
297
+ "schema": schema
298
+ }
299
+
300
+ def _create_response_schema(self, func: Callable) -> Dict[str, Any]:
301
+ """Create response schema from function return annotation"""
302
+ try:
303
+ sig = inspect.signature(func)
304
+ return_annotation = sig.return_annotation
305
+
306
+ if return_annotation == inspect.Parameter.empty:
307
+ return {"type": "object"} # Default to object
308
+
309
+ annotation_str = str(return_annotation)
310
+
311
+ if "dict" in annotation_str.lower():
312
+ return {
313
+ "type": "object",
314
+ "additionalProperties": True
315
+ }
316
+ elif "list" in annotation_str.lower():
317
+ if hasattr(return_annotation, '__args__') and return_annotation.__args__:
318
+ item_type = str(return_annotation.__args__[0])
319
+ if "str" in item_type.lower():
320
+ return {"type": "array", "items": {"type": "string"}}
321
+ elif "int" in item_type.lower():
322
+ return {"type": "array", "items": {"type": "integer"}}
323
+ else:
324
+ return {"type": "array", "items": {"type": "object"}}
325
+ else:
326
+ return {"type": "array", "items": {"type": "object"}}
327
+ elif "str" in annotation_str.lower():
328
+ return {"type": "string"}
329
+ elif "int" in annotation_str.lower():
330
+ return {"type": "integer"}
331
+ elif "bool" in annotation_str.lower():
332
+ return {"type": "boolean"}
333
+ else:
334
+ return {"type": "object"}
335
+
336
+ except Exception as e:
337
+ logger.error(f"Error creating response schema: {e}")
338
+ return {"type": "object"}
339
+
340
+ def _get_function_summary(self, func: Callable) -> str:
341
+ """Get a summary for a function"""
342
+ func_name = func.__name__
343
+
344
+ # Convert snake_case to Title Case
345
+ return ' '.join(word.capitalize() for word in func_name.split('_'))
346
+
347
+ def generate_openapi_spec(self, servers: List[Dict[str, str]] = None) -> Dict[str, Any]:
348
+ """Generate complete OpenAPI 3.0 specification"""
349
+
350
+ # Create info object
351
+ info = OpenAPIInfo(
352
+ title=self.agent_name,
353
+ version="1.0.0",
354
+ description=self.agent_description or f"OpenAPI specification for {self.agent_name}",
355
+ contact={
356
+ "name": "AgentMCP",
357
+ "url": "https://github.com/agentmcp"
358
+ },
359
+ license={
360
+ "name": "MIT",
361
+ "url": "https://opensource.org/licenses/MIT"
362
+ }
363
+ )
364
+
365
+ # Group paths by path
366
+ paths = {}
367
+ for path_obj in self.paths:
368
+ if path_obj.path not in paths:
369
+ paths[path_obj.path] = {}
370
+
371
+ paths[path_obj.path][path_obj.method.lower()] = {
372
+ "operationId": path_obj.operation_id,
373
+ "summary": path_obj.summary,
374
+ "description": path_obj.description,
375
+ "tags": path_obj.tags,
376
+ "parameters": path_obj.parameters,
377
+ "responses": path_obj.responses
378
+ }
379
+
380
+ # Default servers
381
+ if not servers:
382
+ servers = [
383
+ {
384
+ "url": "http://localhost:8000",
385
+ "description": "Development server"
386
+ }
387
+ ]
388
+
389
+ # Create tags
390
+ tags_set = set()
391
+ for path_obj in self.paths:
392
+ tags_set.update(path_obj.tags)
393
+
394
+ tags = [
395
+ {"name": tag, "description": f"Operations related to {tag}"}
396
+ for tag in sorted(list(tags_set))
397
+ ]
398
+
399
+ # Complete OpenAPI spec
400
+ spec = {
401
+ "openapi": "3.0.0",
402
+ "info": asdict(info),
403
+ "servers": servers,
404
+ "paths": paths,
405
+ "tags": tags,
406
+ "components": {
407
+ "schemas": self.schemas
408
+ }
409
+ }
410
+
411
+ return spec
412
+
413
+ class OpenAPIServer:
414
+ """Serve OpenAPI specification and handle requests"""
415
+
416
+ def __init__(
417
+ self,
418
+ agent_id: str,
419
+ agent_info: Dict[str, Any],
420
+ mcp_tools: Dict[str, Any] = None,
421
+ host: str = "0.0.0.0",
422
+ port: int = 8080
423
+ ):
424
+ self.agent_id = agent_id
425
+ self.agent_info = agent_info
426
+ self.mcp_tools = mcp_tools or {}
427
+ self.host = host
428
+ self.port = port
429
+
430
+ # Generate OpenAPI spec
431
+ self.generator = OpenAPIGenerator(
432
+ agent_name=agent_info.get("name", agent_id),
433
+ agent_description=agent_info.get("description", "")
434
+ )
435
+
436
+ # Add agent info paths
437
+ self.generator.add_agent_info_paths(agent_id, agent_info)
438
+
439
+ # Add MCP tools as paths
440
+ if self.mcp_tools:
441
+ self.generator.add_mcp_tools_as_paths(self.mcp_tools)
442
+
443
+ # Generate the spec
444
+ self.openapi_spec = self.generator.generate_openapi_spec([
445
+ {"url": f"http://{host}:{port}", "description": "Development server"}
446
+ ])
447
+
448
+ def get_spec_json(self) -> str:
449
+ """Get OpenAPI specification as JSON"""
450
+ return json.dumps(self.openapi_spec, indent=2)
451
+
452
+ def get_spec_yaml(self) -> str:
453
+ """Get OpenAPI specification as YAML"""
454
+ try:
455
+ import yaml
456
+ return yaml.dump(self.openapi_spec, default_flow_style=False)
457
+ except ImportError:
458
+ logger.warning("PyYAML not available, returning JSON")
459
+ return self.get_spec_json()
460
+
461
+ async def handle_openapi_request(
462
+ self,
463
+ method: str,
464
+ path: str,
465
+ query_params: Dict[str, str] = None,
466
+ body: Dict[str, Any] = None
467
+ ) -> Dict[str, Any]:
468
+ """Handle a request to the OpenAPI endpoints"""
469
+ try:
470
+ # Normalize path
471
+ if not path.startswith('/'):
472
+ path = '/' + path
473
+
474
+ # Find matching path in our spec
475
+ path_obj = None
476
+ for p in self.generator.paths:
477
+ if p.path == path:
478
+ path_obj = p
479
+ break
480
+
481
+ if not path_obj:
482
+ return {
483
+ "error": "Path not found",
484
+ "status_code": 404
485
+ }
486
+
487
+ # Check method
488
+ if path_obj.method.lower() != method.lower():
489
+ return {
490
+ "error": f"Method {method} not allowed for {path}",
491
+ "status_code": 405
492
+ }
493
+
494
+ # Execute the operation
495
+ if path == "/agent/info":
496
+ return await self._handle_agent_info()
497
+ elif path == "/agent/health":
498
+ return await self._handle_agent_health()
499
+ elif path == "/agent/capabilities":
500
+ return await self._handle_agent_capabilities()
501
+ elif path.startswith("/tools/") and self.mcp_tools:
502
+ tool_name = path.replace("/tools/", "")
503
+ if tool_name in self.mcp_tools:
504
+ return await self._handle_tool_call(tool_name, body or query_params or {})
505
+
506
+ return {
507
+ "error": "Operation not implemented",
508
+ "status_code": 501
509
+ }
510
+
511
+ except Exception as e:
512
+ logger.error(f"Error handling OpenAPI request: {e}")
513
+ return {
514
+ "error": str(e),
515
+ "status_code": 500
516
+ }
517
+
518
+ async def _handle_agent_info(self) -> Dict[str, Any]:
519
+ """Handle agent info request"""
520
+ return {
521
+ "agent_id": self.agent_id,
522
+ "name": self.agent_info.get("name", self.agent_id),
523
+ "description": self.agent_info.get("description", ""),
524
+ "framework": self.agent_info.get("framework", "unknown"),
525
+ "capabilities": self.agent_info.get("capabilities", []),
526
+ "tools": list(self.mcp_tools.keys()),
527
+ "openapi_spec": self.openapi_spec,
528
+ "timestamp": datetime.now(timezone.utc).isoformat()
529
+ }
530
+
531
+ async def _handle_agent_health(self) -> Dict[str, Any]:
532
+ """Handle health check request"""
533
+ return {
534
+ "status": "healthy",
535
+ "timestamp": datetime.now(timezone.utc).isoformat(),
536
+ "agent_id": self.agent_id,
537
+ "uptime_seconds": 3600, # Would be calculated in real implementation
538
+ "memory_usage_mb": 128, # Would be monitored in real implementation
539
+ "cpu_usage_percent": 15.0 # Would be monitored in real implementation
540
+ }
541
+
542
+ async def _handle_agent_capabilities(self) -> Dict[str, Any]:
543
+ """Handle capabilities request"""
544
+ return {
545
+ "agent_id": self.agent_id,
546
+ "framework": self.agent_info.get("framework", "unknown"),
547
+ "capabilities": self.agent_info.get("capabilities", []),
548
+ "tools": [
549
+ {
550
+ "name": tool_name,
551
+ "description": tool_info.get("description", ""),
552
+ "parameters": tool_info.get("parameters", [])
553
+ }
554
+ for tool_name, tool_info in self.mcp_tools.items()
555
+ ],
556
+ "openapi_endpoints": [
557
+ {
558
+ "path": path_obj.path,
559
+ "method": path_obj.method,
560
+ "operation_id": path_obj.operation_id,
561
+ "summary": path_obj.summary
562
+ }
563
+ for path_obj in self.generator.paths
564
+ ],
565
+ "timestamp": datetime.now(timezone.utc).isoformat()
566
+ }
567
+
568
+ async def _handle_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
569
+ """Handle tool execution request"""
570
+ if tool_name not in self.mcp_tools:
571
+ return {
572
+ "error": f"Tool {tool_name} not found",
573
+ "available_tools": list(self.mcp_tools.keys())
574
+ }
575
+
576
+ tool_info = self.mcp_tools[tool_name]
577
+ tool_func = tool_info.get("function")
578
+
579
+ if not tool_func:
580
+ return {
581
+ "error": f"Tool {tool_name} has no executable function"
582
+ }
583
+
584
+ try:
585
+ # Execute the tool
586
+ if asyncio.iscoroutinefunction(tool_func):
587
+ result = await tool_func(**arguments)
588
+ else:
589
+ result = tool_func(**arguments)
590
+
591
+ return {
592
+ "tool_name": tool_name,
593
+ "arguments": arguments,
594
+ "result": result,
595
+ "status": "success",
596
+ "timestamp": datetime.now(timezone.utc).isoformat()
597
+ }
598
+
599
+ except Exception as e:
600
+ logger.error(f"Error executing tool {tool_name}: {e}")
601
+ return {
602
+ "tool_name": tool_name,
603
+ "arguments": arguments,
604
+ "error": str(e),
605
+ "status": "error",
606
+ "timestamp": datetime.now(timezone.utc).isoformat()
607
+ }
608
+
609
+ # Export classes for easy importing
610
+ __all__ = [
611
+ 'OpenAPIInfo',
612
+ 'OpenAPIPath',
613
+ 'OpenAPISchema',
614
+ 'OpenAPIGenerator',
615
+ 'OpenAPIServer'
616
+ ]