diff --git a/asab/mcp/__init__.py b/asab/mcp/__init__.py new file mode 100644 index 000000000..b76571277 --- /dev/null +++ b/asab/mcp/__init__.py @@ -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", +] diff --git a/asab/mcp/datacls.py b/asab/mcp/datacls.py new file mode 100644 index 000000000..bcf5d4558 --- /dev/null +++ b/asab/mcp/datacls.py @@ -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 diff --git a/asab/mcp/decorators.py b/asab/mcp/decorators.py new file mode 100644 index 000000000..45a8fac30 --- /dev/null +++ b/asab/mcp/decorators.py @@ -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 diff --git a/asab/mcp/service.py b/asab/mcp/service.py new file mode 100644 index 000000000..67e6eb9f6 --- /dev/null +++ b/asab/mcp/service.py @@ -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] + + 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, + } + + + 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 diff --git a/asab/mcp/utils.py b/asab/mcp/utils.py new file mode 100644 index 000000000..eed4144e6 --- /dev/null +++ b/asab/mcp/utils.py @@ -0,0 +1,45 @@ + +def rpc_ping(*args, **kwargs): + """ + MCP ping method - health check endpoint. + Returns an empty result to confirm the server is alive and responsive. + + https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping + """ + return {} + + +def prune_nulls(value): + """ + Recursively remove keys with value None, list items that are None, + and any empty dicts/lists that become empty as a result. + """ + if isinstance(value, dict): + pruned = {} + for key, item in value.items(): + if item is None: + continue + if key.startswith("_"): + continue + cleaned = prune_nulls(item) + if cleaned is None: + continue + if isinstance(cleaned, (dict, list)) and len(cleaned) == 0: + continue + pruned[key] = cleaned + return pruned + + if isinstance(value, list): + pruned_items = [] + for item in value: + if item is None: + continue + cleaned = prune_nulls(item) + if cleaned is None: + continue + if isinstance(cleaned, (dict, list)) and len(cleaned) == 0: + continue + pruned_items.append(cleaned) + return pruned_items + + return value diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index b27377123..000000000 Binary files a/docs/.DS_Store and /dev/null differ diff --git a/docs/overrides/.DS_Store b/docs/overrides/.DS_Store deleted file mode 100644 index 518f0b8f8..000000000 Binary files a/docs/overrides/.DS_Store and /dev/null differ diff --git a/examples/mcp_server.py b/examples/mcp_server.py new file mode 100644 index 000000000..eef4d29e0 --- /dev/null +++ b/examples/mcp_server.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +import asab +import asab.web +import asab.mcp + + +class MyMCPServerApplication(asab.Application): + + def __init__(self): + super().__init__() + + # Create the Web server + web = asab.web.create_web_server(self, api=True) + + # Add the MCP service, it will be used to register tools and resources + self.MCPService = asab.mcp.MCPService(self, web) + + # Add the hello world tool + self.MCPService.add_tool(self.tool_hello_world) + + + @asab.mcp.mcp_tool( + name="hello_world", + title="Hello world", + description=""" + Says hello to the given name. + + Args: + name: The name to greet + + Returns: + A string with the greeting + """, + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + ) + async def tool_hello_world(self, name: str): + ''' + Hello world tool, this method is exposed to the MCP client. + + Args: + name: The name to greet + + Returns: + A string with the greeting + ''' + return "Hello, {}!".format(name) + + +if __name__ == '__main__': + app = MyMCPServerApplication() + app.run() diff --git a/setup.py b/setup.py index 370b3b5df..b8ee96fa5 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,6 @@ def run(self): f.write("__build__ = '{}'\n".format(build)) f.write("\n") - setup( name='asab', version=version, @@ -92,7 +91,8 @@ def run(self): 'git': ['pygit2<1.12'], 'encryption': ['cryptography'], 'authz': ['jwcrypto==1.5.6'], - 'monitoring': ['sentry-sdk==1.45.0'] + 'monitoring': ['sentry-sdk==1.45.0'], + 'mcp': ['aiohttp-rpc'], }, cmdclass={ 'build_py': CustomBuildPy,