From a2d4170b76edcb467dfb8ad94e53e66133c8fa88 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 09:57:48 +0000 Subject: [PATCH 01/13] Initial code import of the MCP server for ASAB ecosystem. --- asab/mcp/__init__.py | 11 ++ asab/mcp/datacls.py | 46 +++++++ asab/mcp/decorators.py | 28 +++++ asab/mcp/service.py | 275 +++++++++++++++++++++++++++++++++++++++++ asab/mcp/utils.py | 58 +++++++++ setup.py | 4 +- 6 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 asab/mcp/__init__.py create mode 100644 asab/mcp/datacls.py create mode 100644 asab/mcp/decorators.py create mode 100644 asab/mcp/service.py create mode 100644 asab/mcp/utils.py diff --git a/asab/mcp/__init__.py b/asab/mcp/__init__.py new file mode 100644 index 000000000..02f673282 --- /dev/null +++ b/asab/mcp/__init__.py @@ -0,0 +1,11 @@ +from .service import MCPService +from .decorators import mcp_tool, mcp_resource_template +from .datacls import MCPToolInfo, MCPResourceTemplateInfo + +__all__ = [ + "MCPService", + "mcp_tool", + "mcp_resource_template", + "MCPToolInfo", + "MCPResourceTemplateInfo", +] 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..f314ab0d2 --- /dev/null +++ b/asab/mcp/service.py @@ -0,0 +1,275 @@ +import logging +import dataclasses + +import asab + +import aiohttp_rpc + +from .utils import rcpcall_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"): + super().__init__(app, service_name) + + self.Tools = {} + self.ResourceTemplates = {} + self.ResourceLists = {} + + 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._rcpcall_mcp_initialize, name="initialize")) + self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_notifications_initialized, name="notifications/initialized")) + self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(rcpcall_ping, name="ping")) + + self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_tools_list, name="tools/list")) + self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_tools_call, name="tools/call")) + + self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_resources_list, name="resources/list")) + self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_resources_read, name="resources/read")) + + self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_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 + + + async def _handle_http_request(self, request): + # TODO: Handle tenant and authorization + return await self.RPCServer.handle_http_request(request) + + + async def _rcpcall_mcp_initialize(self, capabilities=None, clientInfo=None, *args, **kwargs): + capabilities = capabilities or {} + clientInfo = clientInfo or {} + + L.log(asab.LOG_NOTICE, "MCP Client initializing", struct_data={ + "name": clientInfo.get('name', 'unknown'), + "version": clientInfo.get('version', 'unknown'), + + }) + + capabilities = {} + 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": "asab-mcp", + "version": "25.11.0", + }, + "instructions": ( + "ASAB MCP server is ready." + ), + "capabilities": capabilities, + } + + + async def _rcpcall_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 _rcpcall_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 + ''' + + 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 occured." + }], + "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 shotcut 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 _rcpcall_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 _rcpcall_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 _rcpcall_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 _rcpcall_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..26fac0654 --- /dev/null +++ b/asab/mcp/utils.py @@ -0,0 +1,58 @@ + +def rcpcall_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 + + + +def uri_template_match_(pattern, uri: str) -> dict: + ''' + Check if the URI matches the URI template. + Returns a dictionary of the variables in the URI template or None + + According to RFC 6570 URI Template Matching + ''' + + # URI template is ie "note://{path}", uri is e.g. "note://notes/mynote.md" + return pattern == uri 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, From 88e1d1a4909ed365d3b0ceb57748264c2d23dcda Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 11:02:42 +0100 Subject: [PATCH 02/13] Update asab/mcp/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- asab/mcp/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asab/mcp/utils.py b/asab/mcp/utils.py index 26fac0654..1db29f4c3 100644 --- a/asab/mcp/utils.py +++ b/asab/mcp/utils.py @@ -1,5 +1,5 @@ -def rcpcall_ping(*args, **kwargs): +def rpccall_ping(*args, **kwargs): """ MCP ping method - health check endpoint. Returns an empty result to confirm the server is alive and responsive. From 82f98c4732fb0c1cb0fdad9eed329826de9e3dec Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 11:03:00 +0100 Subject: [PATCH 03/13] Update asab/mcp/service.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- asab/mcp/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asab/mcp/service.py b/asab/mcp/service.py index f314ab0d2..d97f5310b 100644 --- a/asab/mcp/service.py +++ b/asab/mcp/service.py @@ -152,7 +152,7 @@ async def _rcpcall_tools_call(self, name, arguments, *args, **kwargs): "text": item.text, }) elif isinstance(item, str): - # A shotcut for Text content. + # A shortcut for Text content. transformed_result.append({ "type": "text", "text": item, From 21c8bf61e7fdb932c39b0dd3af95f6c329fac6b1 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 11:03:19 +0100 Subject: [PATCH 04/13] Update asab/mcp/service.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- asab/mcp/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asab/mcp/service.py b/asab/mcp/service.py index d97f5310b..ec6b609e4 100644 --- a/asab/mcp/service.py +++ b/asab/mcp/service.py @@ -136,7 +136,7 @@ async def _rcpcall_tools_call(self, name, arguments, *args, **kwargs): return { "content": [{ "type": "text", - "text": "General error occured." + "text": "General error occurred." }], "isError": True, } From 450ac8e1af3e8a7c5c3ff318cdbf4a2e68159936 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 10:05:18 +0000 Subject: [PATCH 05/13] Add a TODO. --- asab/mcp/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/asab/mcp/utils.py b/asab/mcp/utils.py index 1db29f4c3..e5efb5f2e 100644 --- a/asab/mcp/utils.py +++ b/asab/mcp/utils.py @@ -54,5 +54,7 @@ def uri_template_match_(pattern, uri: str) -> dict: According to RFC 6570 URI Template Matching ''' + # TODO: This is a placeholder implementation, more complete implementation of RFC 6570 URI Template Matching is needed + # URI template is ie "note://{path}", uri is e.g. "note://notes/mynote.md" return pattern == uri From 1ea7244b33febf8d4ea12a59b9694ac02301f9f9 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 10:10:52 +0000 Subject: [PATCH 06/13] Naming fix --- asab/mcp/service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asab/mcp/service.py b/asab/mcp/service.py index ec6b609e4..56976826e 100644 --- a/asab/mcp/service.py +++ b/asab/mcp/service.py @@ -5,7 +5,7 @@ import aiohttp_rpc -from .utils import rcpcall_ping, prune_nulls +from .utils import rpccall_ping, prune_nulls from .datacls import MCPToolResultTextContent, MCPToolResultResourceLink @@ -26,7 +26,7 @@ def __init__(self, app, web, service_name="asab.MCPService"): self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_mcp_initialize, name="initialize")) self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_notifications_initialized, name="notifications/initialized")) - self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(rcpcall_ping, name="ping")) + self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(rpccall_ping, name="ping")) self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_tools_list, name="tools/list")) self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_tools_call, name="tools/call")) From fc47a785d6a7214e7f28c3f9007fb3789eb42353 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 10:11:01 +0000 Subject: [PATCH 07/13] Removal of the obsolete function. --- asab/mcp/utils.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/asab/mcp/utils.py b/asab/mcp/utils.py index e5efb5f2e..50d509b94 100644 --- a/asab/mcp/utils.py +++ b/asab/mcp/utils.py @@ -43,18 +43,3 @@ def prune_nulls(value): return pruned_items return value - - - -def uri_template_match_(pattern, uri: str) -> dict: - ''' - Check if the URI matches the URI template. - Returns a dictionary of the variables in the URI template or None - - According to RFC 6570 URI Template Matching - ''' - - # TODO: This is a placeholder implementation, more complete implementation of RFC 6570 URI Template Matching is needed - - # URI template is ie "note://{path}", uri is e.g. "note://notes/mynote.md" - return pattern == uri From 058a9c2e6cb06a3d0c732f6be61c1045c0839a07 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 10:23:37 +0000 Subject: [PATCH 08/13] Renaming. --- asab/mcp/service.py | 32 ++++++++++++++++---------------- asab/mcp/utils.py | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/asab/mcp/service.py b/asab/mcp/service.py index 56976826e..e8fab57eb 100644 --- a/asab/mcp/service.py +++ b/asab/mcp/service.py @@ -5,7 +5,7 @@ import aiohttp_rpc -from .utils import rpccall_ping, prune_nulls +from .utils import rpc_ping, prune_nulls from .datacls import MCPToolResultTextContent, MCPToolResultResourceLink @@ -24,17 +24,17 @@ def __init__(self, app, web, service_name="asab.MCPService"): 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._rcpcall_mcp_initialize, name="initialize")) - self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_notifications_initialized, name="notifications/initialized")) - self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(rpccall_ping, name="ping")) + 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._rcpcall_tools_list, name="tools/list")) - self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_tools_call, name="tools/call")) + 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._rcpcall_resources_list, name="resources/list")) - self.RPCServer.add_method(aiohttp_rpc.JsonRpcMethod(self._rcpcall_resources_read, name="resources/read")) + 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._rcpcall_resource_templates_list, name="resources/templates/list")) + 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): @@ -66,7 +66,7 @@ async def _handle_http_request(self, request): return await self.RPCServer.handle_http_request(request) - async def _rcpcall_mcp_initialize(self, capabilities=None, clientInfo=None, *args, **kwargs): + async def _rpc_mcp_initialize(self, capabilities=None, clientInfo=None, *args, **kwargs): capabilities = capabilities or {} clientInfo = clientInfo or {} @@ -100,7 +100,7 @@ async def _rcpcall_mcp_initialize(self, capabilities=None, clientInfo=None, *arg } - async def _rcpcall_tools_list(self, *args, **kwargs): + async def _rcp_tools_list(self, *args, **kwargs): ''' To discover available tools, clients send a tools/list request. @@ -115,7 +115,7 @@ async def _rcpcall_tools_list(self, *args, **kwargs): } - async def _rcpcall_tools_call(self, name, arguments, *args, **kwargs): + async def _rpc_tools_call(self, name, arguments, *args, **kwargs): ''' To invoke a tool, clients send a tools/call request. @@ -174,7 +174,7 @@ async def _rcpcall_tools_call(self, name, arguments, *args, **kwargs): } - async def _rcpcall_resources_list(self, *args, **kwargs): + async def _rpc_resources_list(self, *args, **kwargs): ''' To list resources, clients send a resources/list request. @@ -203,7 +203,7 @@ async def _rcpcall_resources_list(self, *args, **kwargs): } - async def _rcpcall_resources_read(self, uri, *args, **kwargs): + async def _rpc_resources_read(self, uri, *args, **kwargs): ''' To read a resource, clients send a resources/read request. @@ -238,7 +238,7 @@ async def _rcpcall_resources_read(self, uri, *args, **kwargs): } - async def _rcpcall_resource_templates_list(self, *args, **kwargs): + async def _rpc_resource_templates_list(self, *args, **kwargs): ''' To discover available resource templates, clients send a resources/templates/list request. @@ -253,7 +253,7 @@ async def _rcpcall_resource_templates_list(self, *args, **kwargs): } - async def _rcpcall_notifications_initialized(self, *args, **kwargs): + async def _rpc_notifications_initialized(self, *args, **kwargs): """ This notification is sent from the client to the server after initialization has finished. diff --git a/asab/mcp/utils.py b/asab/mcp/utils.py index 50d509b94..eed4144e6 100644 --- a/asab/mcp/utils.py +++ b/asab/mcp/utils.py @@ -1,5 +1,5 @@ -def rpccall_ping(*args, **kwargs): +def rpc_ping(*args, **kwargs): """ MCP ping method - health check endpoint. Returns an empty result to confirm the server is alive and responsive. From a952bda2485f0ff45978e6103dc0f1cde03bc8d1 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 18:35:24 +0000 Subject: [PATCH 09/13] Add the example of the MCP server. --- examples/mcp_server.py | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 examples/mcp_server.py 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() From b752842d349aee688eb24f27533ce8df6002a38b Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 18:48:31 +0000 Subject: [PATCH 10/13] Extending imports/exports in MCP. --- asab/mcp/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/asab/mcp/__init__.py b/asab/mcp/__init__.py index 02f673282..b76571277 100644 --- a/asab/mcp/__init__.py +++ b/asab/mcp/__init__.py @@ -1,6 +1,6 @@ from .service import MCPService from .decorators import mcp_tool, mcp_resource_template -from .datacls import MCPToolInfo, MCPResourceTemplateInfo +from .datacls import MCPToolInfo, MCPResourceTemplateInfo, MCPToolResultTextContent, MCPToolResultResourceLink __all__ = [ "MCPService", @@ -8,4 +8,6 @@ "mcp_resource_template", "MCPToolInfo", "MCPResourceTemplateInfo", + "MCPToolResultTextContent", + "MCPToolResultResourceLink", ] From e039931ea7e1b590b54bf1fa758e9011749feac8 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 22:14:53 +0000 Subject: [PATCH 11/13] Improving on reusability and flexibility of MCP server. --- asab/mcp/service.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/asab/mcp/service.py b/asab/mcp/service.py index e8fab57eb..ed0325fc7 100644 --- a/asab/mcp/service.py +++ b/asab/mcp/service.py @@ -14,12 +14,16 @@ class MCPService(asab.Service): - def __init__(self, app, web, service_name="asab.MCPService"): + 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) @@ -61,6 +65,10 @@ 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) @@ -76,6 +84,10 @@ async def _rpc_mcp_initialize(self, capabilities=None, clientInfo=None, *args, * }) + instructions = "" + for instruction in self.Instructions.values(): + instructions += instruction + "\n" + capabilities = {} if len(self.Tools) > 0: capabilities['tools'] = { @@ -90,12 +102,10 @@ async def _rpc_mcp_initialize(self, capabilities=None, clientInfo=None, *args, * return { "protocolVersion": "2024-11-05", "serverInfo": { - "name": "asab-mcp", - "version": "25.11.0", + "name": self.Name, + "version": self.Version, }, - "instructions": ( - "ASAB MCP server is ready." - ), + "instructions": instructions, "capabilities": capabilities, } From 24e91f532daf7116d15b279d19b4bbf569524594 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Wed, 19 Nov 2025 22:52:23 +0000 Subject: [PATCH 12/13] More defensibility. --- asab/mcp/service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asab/mcp/service.py b/asab/mcp/service.py index ed0325fc7..67e6eb9f6 100644 --- a/asab/mcp/service.py +++ b/asab/mcp/service.py @@ -75,7 +75,7 @@ async def _handle_http_request(self, request): async def _rpc_mcp_initialize(self, capabilities=None, clientInfo=None, *args, **kwargs): - capabilities = capabilities or {} + capabilities = capabilities.copy() or {} clientInfo = clientInfo or {} L.log(asab.LOG_NOTICE, "MCP Client initializing", struct_data={ @@ -88,7 +88,6 @@ async def _rpc_mcp_initialize(self, capabilities=None, clientInfo=None, *args, * for instruction in self.Instructions.values(): instructions += instruction + "\n" - capabilities = {} if len(self.Tools) > 0: capabilities['tools'] = { 'listChanged': True, @@ -131,6 +130,7 @@ async def _rpc_tools_call(self, name, arguments, *args, **kwargs): https://modelcontextprotocol.io/specification/2025-06-18/server/tools#invoking-tools ''' + arguments = arguments or {} x = self.Tools.get(name) if x is None: From 25f443ca65499a491ec203f8058c2742aaa04121 Mon Sep 17 00:00:00 2001 From: Ales Teska Date: Sat, 27 Dec 2025 13:56:23 +0000 Subject: [PATCH 13/13] Removal of the incorrectly added files. --- docs/.DS_Store | Bin 8196 -> 0 bytes docs/overrides/.DS_Store | Bin 6148 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/.DS_Store delete mode 100644 docs/overrides/.DS_Store diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index b2737712342f9ac6ad59837d7c2cb1564fa424f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMO>fgc5S?v9<0yg#1fmxtOI%ASZA3v_LMR7LNTh-Tpb*EVvFg~7?KD(TC0~L6 zz+d19zl8t73Eu2(i@Rw+>Y=K{uC)6$_PqUgo>_NXA`LtJ*<74C?|pUGwpIbFz<;R#pARl7 zV@qS9P#zs62t~(zm(77KjfFx*C!y#h z^vptMC_>H-o-55sv=rLbDqt0uRe*E%W$IHxF|zh@{vOjVt^EFdu(s;C0@? zJUM1fNYp;A0F168@KO1dePmB zhH>%{%SRFVOr^`}_i_6Py%XEt@1${*#Jp|hI8uIJKD>FZ@}=Y|HORdW>r^@#^9(_W z9pfL2lC0m)UDlQ9bYt@YbcL4lb!FX9=IY80d5RW>x^)>sV>li=IR{dX(8>*W<EB2hMx|(xSE^5Cjx);E*ry2cE+}cm;x4@3u;n z=890YEA5Ws?AXcJ%Ekb~XgNIv1^{|=!Oj807L)tp4eMCXJ~5;*9TOJ|t#s-5>)nnP61zZ7FV6A|BA0k~aGprQTr-LCK0f=38Cu3WF6NM8EGs8-ecW6PWM5P8h zVg#kLKU!X9SSczU!44n6W)5~j32AnYA5%I)rs&ZXa0NCM*wf2_)c^DL^ZzEvuUr9F zV5bxa-SOpk#8fDL$qrV}FzeF*B?b*+cUmfh2