-
Notifications
You must be signed in to change notification settings - Fork 7
MCP server for ASAB ecosystem. #718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
a2d4170
88e1d1a
82f98c4
21c8bf6
450ac8e
1ea7244
fc47a78
058a9c2
a952bda
b752842
e039931
24e91f5
25f443c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| from .service import MCPService | ||
| from .decorators import mcp_tool, mcp_resource_template | ||
| from .datacls import MCPToolInfo, MCPResourceTemplateInfo, MCPToolResultTextContent, MCPToolResultResourceLink | ||
|
|
||
| __all__ = [ | ||
| "MCPService", | ||
| "mcp_tool", | ||
| "mcp_resource_template", | ||
| "MCPToolInfo", | ||
| "MCPResourceTemplateInfo", | ||
| "MCPToolResultTextContent", | ||
| "MCPToolResultResourceLink", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import dataclasses | ||
|
|
||
|
|
||
| @dataclasses.dataclass | ||
| class MCPToolInfo: | ||
| name: str | ||
| title: str | ||
| description: str | ||
| inputSchema: dict | ||
| outputSchema: dict | ||
|
|
||
|
|
||
| @dataclasses.dataclass | ||
| class MCPToolResult: | ||
| pass | ||
|
|
||
|
|
||
| @dataclasses.dataclass | ||
| class MCPToolResultTextContent(MCPToolResult): | ||
| ''' | ||
| https://modelcontextprotocol.io/specification/2025-06-18/server/tools#text-content | ||
| ''' | ||
| text: str | ||
|
|
||
|
|
||
| @dataclasses.dataclass | ||
| class MCPToolResultResourceLink(MCPToolResult): | ||
| ''' | ||
| https://modelcontextprotocol.io/specification/2025-06-18/server/tools#resource-links | ||
| ''' | ||
| uri: str | ||
| name: str | ||
| description: str | ||
| mimeType: str | ||
| title: str = None # For resource listing | ||
| # TODO: Resource annotations | ||
|
|
||
|
|
||
| @dataclasses.dataclass | ||
| class MCPResourceTemplateInfo: | ||
| _uriPrefix: str | ||
| uriTemplate: str | ||
| name: str | ||
| title: str | ||
| description: str | ||
| mimeType: str |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| from .datacls import MCPToolInfo, MCPResourceTemplateInfo | ||
|
|
||
|
|
||
| def mcp_tool(name, title, description, inputSchema=None, outputSchema=None): | ||
| def decorator(func): | ||
| func._mcp_tool_info = MCPToolInfo( | ||
| name=name.strip(), | ||
| title=title.strip(), | ||
| description=description.strip(), | ||
| inputSchema=inputSchema, | ||
| outputSchema=outputSchema, | ||
| ) | ||
| return func | ||
| return decorator | ||
|
|
||
|
|
||
| def mcp_resource_template(uri_prefix: str, uri_template: str, name: str, title: str, description: str, mimeType: str): | ||
| def decorator(func): | ||
| func._mcp_resource_template_info = MCPResourceTemplateInfo( | ||
| uriTemplate=uri_template, | ||
| _uriPrefix=uri_prefix, | ||
| name=name.strip(), | ||
| title=title.strip(), | ||
| description=description.strip(), | ||
| mimeType=mimeType, | ||
| ) | ||
| return func | ||
| return decorator |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| import logging | ||
| import dataclasses | ||
|
|
||
| import asab | ||
|
|
||
| import aiohttp_rpc | ||
|
|
||
| from .utils import rpc_ping, prune_nulls | ||
| from .datacls import MCPToolResultTextContent, MCPToolResultResourceLink | ||
|
|
||
|
|
||
| L = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class MCPService(asab.Service): | ||
|
|
||
| def __init__(self, app, web, service_name="asab.MCPService", name="asab-mcp", version="25.11.0"): | ||
| super().__init__(app, service_name) | ||
|
|
||
| self.Tools = {} | ||
| self.ResourceTemplates = {} | ||
| self.ResourceLists = {} | ||
| self.Instructions = {} | ||
|
|
||
| self.Name = name | ||
| self.Version = version | ||
|
|
||
| self.RPCServer = aiohttp_rpc.JsonRpcServer(middlewares=[logging_middleware]) | ||
| web.add_post(r'/{tenant}/mcp', self._handle_http_request) | ||
|
|
||
| self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rpc_mcp_initialize, name="initialize")) | ||
| self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rpc_notifications_initialized, name="notifications/initialized")) | ||
| self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(rpc_ping, name="ping")) | ||
|
|
||
| self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcp_tools_list, name="tools/list")) | ||
| self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rpc_tools_call, name="tools/call")) | ||
|
|
||
| self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rpc_resources_list, name="resources/list")) | ||
| self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rpc_resources_read, name="resources/read")) | ||
|
|
||
| self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rpc_resource_templates_list, name="resources/templates/list")) | ||
|
|
||
|
|
||
| def add_tool(self, tool_function, mcp_tool_info=None): | ||
| if mcp_tool_info is None and hasattr(tool_function, '_mcp_tool_info'): | ||
| mcp_tool_info = tool_function._mcp_tool_info | ||
|
|
||
| if mcp_tool_info is None: | ||
| raise ValueError("MCP tool info is required") | ||
|
|
||
| self.Tools[mcp_tool_info.name] = (tool_function, mcp_tool_info) | ||
|
|
||
|
|
||
| def add_resource_template(self, resource_template_function, mcp_resource_template_info=None): | ||
| if mcp_resource_template_info is None and hasattr(resource_template_function, '_mcp_resource_template_info'): | ||
| mcp_resource_template_info = resource_template_function._mcp_resource_template_info | ||
|
|
||
| if mcp_resource_template_info is None: | ||
| raise ValueError("MCP resource template info is required") | ||
|
|
||
| self.ResourceTemplates[mcp_resource_template_info.name] = (resource_template_function, mcp_resource_template_info) | ||
|
|
||
|
|
||
| def add_resource_list(self, resource_uri_prefix, resource_list_function): | ||
| self.ResourceLists[resource_uri_prefix] = resource_list_function | ||
|
|
||
|
|
||
| def add_instruction(self, who, instruction): | ||
| self.Instructions[who] = instruction | ||
|
|
||
|
|
||
| async def _handle_http_request(self, request): | ||
| # TODO: Handle tenant and authorization | ||
| return await self.RPCServer.handle_http_request(request) | ||
|
|
||
|
|
||
| async def _rpc_mcp_initialize(self, capabilities=None, clientInfo=None, *args, **kwargs): | ||
| capabilities = capabilities.copy() or {} | ||
| clientInfo = clientInfo or {} | ||
|
|
||
| L.log(asab.LOG_NOTICE, "MCP Client initializing", struct_data={ | ||
| "name": clientInfo.get('name', 'unknown'), | ||
| "version": clientInfo.get('version', 'unknown'), | ||
|
|
||
| }) | ||
|
|
||
| instructions = "" | ||
| for instruction in self.Instructions.values(): | ||
| instructions += instruction + "\n" | ||
|
|
||
| if len(self.Tools) > 0: | ||
| capabilities['tools'] = { | ||
| 'listChanged': True, | ||
| } | ||
|
|
||
| if len(self.ResourceTemplates) > 0 or len(self.ResourceLists) > 0: | ||
| capabilities['resources'] = { | ||
| 'listChanged': True, | ||
| } | ||
|
|
||
| return { | ||
| "protocolVersion": "2024-11-05", | ||
| "serverInfo": { | ||
| "name": self.Name, | ||
| "version": self.Version, | ||
| }, | ||
| "instructions": instructions, | ||
| "capabilities": capabilities, | ||
| } | ||
|
|
||
|
|
||
| async def _rcp_tools_list(self, *args, **kwargs): | ||
| ''' | ||
| To discover available tools, clients send a tools/list request. | ||
|
|
||
| https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools | ||
| ''' | ||
| # TODO: Pagination ... | ||
| return { | ||
| "tools": [ | ||
| prune_nulls(dataclasses.asdict(mcp_tool_info)) | ||
| for _, mcp_tool_info in self.Tools.values() | ||
| ], | ||
| } | ||
|
|
||
|
|
||
| async def _rpc_tools_call(self, name, arguments, *args, **kwargs): | ||
| ''' | ||
| To invoke a tool, clients send a tools/call request. | ||
|
|
||
| https://modelcontextprotocol.io/specification/2025-06-18/server/tools#invoking-tools | ||
| ''' | ||
| arguments = arguments or {} | ||
|
|
||
| x = self.Tools.get(name) | ||
| if x is None: | ||
| L.warning("Tool not found", struct_data={"name": name}) | ||
| raise KeyError(f"Tool {name} not found") | ||
|
|
||
| tool_function, _ = x | ||
|
|
||
| try: | ||
| result = await tool_function(**arguments) | ||
| except Exception as e: | ||
| L.exception("Tool failed", struct_data={"name": name, "error": str(e)}) | ||
| return { | ||
| "content": [{ | ||
| "type": "text", | ||
| "text": "General error occurred." | ||
| }], | ||
| "isError": True, | ||
| } | ||
|
|
||
| if not isinstance(result, list): | ||
| result = [result] | ||
|
|
||
ateska marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| transformed_result = [] | ||
| for item in result: | ||
| if isinstance(item, MCPToolResultTextContent): | ||
| transformed_result.append({ | ||
| "type": "text", | ||
| "text": item.text, | ||
| }) | ||
| elif isinstance(item, str): | ||
| # A shortcut for Text content. | ||
| transformed_result.append({ | ||
| "type": "text", | ||
| "text": item, | ||
| }) | ||
| elif isinstance(item, MCPToolResultResourceLink): | ||
| transformed_result.append({ | ||
| "type": "resource_link", | ||
| "uri": item.uri, | ||
| "name": item.name, | ||
| "description": item.description, | ||
| "mimeType": item.mimeType, | ||
| }) | ||
| else: | ||
| raise ValueError(f"Unsupported result type: {type(item)}") | ||
|
|
||
| return { | ||
| "content": transformed_result, | ||
| "isError": False, | ||
| } | ||
|
|
||
|
|
||
| async def _rpc_resources_list(self, *args, **kwargs): | ||
| ''' | ||
| To list resources, clients send a resources/list request. | ||
|
|
||
| https://modelcontextprotocol.io/specification/2025-06-18/server/resources#listing-resources | ||
| ''' | ||
| resources = [] | ||
|
|
||
| for _, resource_list_function in self.ResourceLists.items(): | ||
| resources.extend(await resource_list_function()) | ||
|
|
||
| transformed_resources = [] | ||
| for resource in resources: | ||
| if isinstance(resource, MCPToolResultResourceLink): | ||
| transformed_resources.append(prune_nulls({ | ||
| "uri": resource.uri, | ||
| "name": resource.name, | ||
| "title": resource.title, | ||
| "description": resource.description, | ||
| "mimeType": resource.mimeType, | ||
| })) | ||
| else: | ||
| raise ValueError(f"Unsupported resource type: {type(resource)}") | ||
|
|
||
| return { | ||
| "resources": transformed_resources, | ||
| } | ||
|
|
||
|
|
||
| async def _rpc_resources_read(self, uri, *args, **kwargs): | ||
| ''' | ||
| To read a resource, clients send a resources/read request. | ||
|
|
||
| https://modelcontextprotocol.io/specification/2025-06-18/server/resources#reading-resources | ||
| ''' | ||
| fnct = None | ||
|
|
||
| # TODO: Check the "direct" | ||
|
|
||
| # Find the resource template function that matches the URI | ||
| if fnct is None: | ||
| for resource_template_function, mcp_resource_template_info in self.ResourceTemplates.values(): | ||
| if uri.startswith(mcp_resource_template_info._uriPrefix): | ||
| fnct = resource_template_function | ||
| break | ||
|
|
||
| if fnct is None: | ||
| # TODO: Find a more compliant way to handle this, but for now we'll just raise an error. | ||
| raise KeyError(f"Resource template {uri} not found") | ||
|
|
||
| result = await fnct(uri) | ||
| if result is None: | ||
| return { | ||
| "contents": [], | ||
| } | ||
|
|
||
| if not isinstance(result, list): | ||
| result = [result] | ||
|
|
||
| return { | ||
| "contents": result, | ||
| } | ||
|
Comment on lines
+216
to
+248
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainEnsure
result = await fnct(uri)
...
if not isinstance(result, list):
result = [result]
return {
"contents": result,
}Unlike tools listing and resource templates listing (which use Two options:
I’d recommend picking one contract and enforcing it here so I'll verify the review comment by examining the codebase to confirm the serialization concerns and compare with other handlers. Length of output: 908 🏁 Script executed: #!/bin/bash
# Search for dataclasses.asdict usage in the codebase
rg -n "asdict" asab/ -B 2 -A 2Length of output: 757 🏁 Script executed: #!/bin/bash
# Look for prune_nulls usage
rg -n "prune_nulls" asab/ -B 2 -A 2Length of output: 1739 🏁 Script executed: #!/bin/bash
# Check for ResourceTemplates definition and usage
rg -n "ResourceTemplates" asab/mcp/service.py -B 2 -A 2Length of output: 963 🏁 Script executed: #!/bin/bash
# Search for JSON encoding/serialization mechanisms
rg -n "json\.dumps\|JSONEncoder\|__dict__" asab/mcp/service.py -B 1 -A 1Length of output: 0 🏁 Script executed: #!/bin/bash
# Look for the complete _rpc_resources_read method and surrounding context
sed -n '210,260p' asab/mcp/service.pyLength of output: 1362 🏁 Script executed: #!/bin/bash
# Search for other _rpc_ methods to compare patterns
rg -n "async def _rpc_" asab/mcp/service.pyLength of output: 426 I need to see the complete Length of output: 1396 🏁 Script executed: #!/bin/bash
# Search for how aiohttp_rpc handles responses and JSON encoding
rg -n "aiohttp_rpc" asab/mcp/service.py -B 2 -A 5Length of output: 1407 🏁 Script executed: #!/bin/bash
# Look for any test files or examples showing resource template function returns
find asab -name "*test*" -o -name "*example*" | head -20Length of output: 0 🏁 Script executed: #!/bin/bash
# Check the custom JSON encoder in web/rest/json.py
cat asab/web/rest/json.pyLength of output: 7978 🏁 Script executed: #!/bin/bash
# Look for where _rpc_ methods are registered/called to understand response flow
rg -n "setattr\|getattr\|RpcHandler" asab/mcp/service.py -B 1 -A 1Length of output: 0 I need to understand how the HTTP responses are serialized for RPC calls. Length of output: 1928 🏁 Script executed: #!/bin/bash
# Search for imports and see if JSONDumper or json_response is used
rg -n "from\|import" asab/mcp/service.py | head -30Length of output: 0 🏁 Script executed: #!/bin/bash
# Look at the file structure to find if there are more details
wc -l asab/mcp/service.pyLength of output: 23 🏁 Script executed: #!/bin/bash
# Check if there's any serialization setup in the service init or elsewhere
rg -n "json\|dumps\|JSONDumper" asab/mcp/service.pyLength of output: 0 🏁 Script executed: #!/bin/bash
# Get info about aiohttp_rpc to see if it has built-in JSON encoder handling
rg -n "JsonRpcServer" asab/mcp/service.py -B 2 -A 10Length of output: 806 Let me search for information about aiohttp_rpc's JSON handling and any examples of resource template functions. 🏁 Script executed: #!/bin/bash
# Look for any examples or tests of resource template usage
find asab -type f -name "*.py" | xargs grep -l "add_resource_template\|resource_template_function" 2>/dev/null | head -5Length of output: 19 🏁 Script executed: #!/bin/bash
# Search entire codebase for any resource template function definitions
rg -n "add_resource_template" asab/ -B 2 -A 10Length of output: 893 🏁 Script executed: #!/bin/bash
# Check MCPToolResultTextContent and related dataclasses to understand structure
rg -n "class MCPToolResult\|class MCPResourceContent" asab/mcp/Length of output: 0 🌐 Web query: 💡 Result: Use the JSONRPCServer's json_serialize hook and pass a json.dumps that knows how to convert dataclasses (and nested non‑JSON types). Example:
Minimal example: from aiohttp import web @DataClass async def get_user(user_id) -> User: def default_json(o): rpc_server = aiohttp_rpc.JSONRPCServer( app = web.Application() mount rpc_server.handle_http_request(...) as your handlerNotes:
Sources: Citations:
Verify the contract: resource template functions may return dataclass instances, and The review comment is correct and well-founded. Here's what verification confirmed:
The two approaches from the review comment remain valid:
🧰 Tools🪛 Ruff (0.14.5)216-216: Unused method argument: (ARG002) 216-216: Unused method argument: (ARG002) 235-235: Avoid specifying long messages outside the exception class (TRY003) |
||
|
|
||
|
|
||
| async def _rpc_resource_templates_list(self, *args, **kwargs): | ||
| ''' | ||
| To discover available resource templates, clients send a resources/templates/list request. | ||
|
|
||
| https://modelcontextprotocol.io/specification/2025-06-18/server/resources#resource-templates | ||
| ''' | ||
| # TODO: Pagination ... | ||
| return { | ||
| "resourceTemplates": [ | ||
| prune_nulls(dataclasses.asdict(mcp_resource_template_info)) | ||
| for _, mcp_resource_template_info in self.ResourceTemplates.values() | ||
| ], | ||
| } | ||
|
|
||
|
|
||
| async def _rpc_notifications_initialized(self, *args, **kwargs): | ||
| """ | ||
| This notification is sent from the client to the server after initialization has finished. | ||
|
|
||
| https://modelcontextprotocol.io/specification/2025-06-18/schema#notifications%2Finitialized | ||
| """ | ||
| L.log(asab.LOG_NOTICE, "MCP Client initialized") | ||
| return {} | ||
|
|
||
|
|
||
| async def logging_middleware(request, handler): | ||
| response = await handler(request) | ||
| if response.error is None: | ||
| L.log(asab.LOG_NOTICE, "JSON-RPC request completed", struct_data={"method": request.method_name}) | ||
| else: | ||
| L.warning("JSON-RPC request failed", struct_data={ | ||
| "method": request.method_name, | ||
| "error": response.error.message, | ||
| }) | ||
| return response | ||
ateska marked this conversation as resolved.
Show resolved
Hide resolved
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix
capabilities.copy()whencapabilitiesisNone.With the MCP initialize request,
capabilitiesis optional; when omitted you’ll hitAttributeErroroncapabilities.copy(). Initializing via theor {}branch needs to happen before calling.copy().You can do:
The rest of the capability augmentation logic looks fine.
📝 Committable suggestion
🧰 Tools
🪛 Ruff (0.14.5)
77-77: Unused method argument:
args(ARG002)
77-77: Unused method argument:
kwargs(ARG002)
🤖 Prompt for AI Agents