From 42ab7f85bfdc1455d00400fa546751c1961bf2e0 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:33:20 +0200 Subject: [PATCH 01/21] MCP Resources --- .../mcp/pyproject.toml | 3 +- .../mcp/src/utcp_mcp/mcp_call_template.py | 4 + .../utcp_mcp/mcp_communication_protocol.py | 279 ++++++++++++++---- .../mcp/tests/mock_mcp_server.py | 17 +- .../mcp/tests/test_mcp_transport.py | 126 ++++++++ 5 files changed, 365 insertions(+), 64 deletions(-) diff --git a/plugins/communication_protocols/mcp/pyproject.toml b/plugins/communication_protocols/mcp/pyproject.toml index 77f0c44..f4aca35 100644 --- a/plugins/communication_protocols/mcp/pyproject.toml +++ b/plugins/communication_protocols/mcp/pyproject.toml @@ -14,7 +14,8 @@ requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", "mcp>=1.12", - "utcp>=1.0" + "utcp>=1.0", + "mcp-use>=1.3" ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_call_template.py b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_call_template.py index 5755804..9d73d4e 100644 --- a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_call_template.py +++ b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_call_template.py @@ -42,11 +42,15 @@ class McpCallTemplate(CallTemplate): config: Configuration object containing MCP server definitions. This follows the same format as the official MCP server configuration. auth: Optional OAuth2 authentication for HTTP-based MCP servers. + register_resources_as_tools: Whether to register MCP resources as callable tools. + When True, server resources are exposed as tools that can be called. + Default is False. """ call_template_type: Literal["mcp"] = "mcp" config: McpConfig auth: Optional[OAuth2Auth] = None + register_resources_as_tools: bool = False class McpCallTemplateSerializer(Serializer[McpCallTemplate]): """REQUIRED diff --git a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py index 261a693..1bd238d 100644 --- a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py +++ b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py @@ -1,9 +1,7 @@ from typing import Any, Dict, Optional, AsyncGenerator, TYPE_CHECKING import json -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.client.streamable_http import streamablehttp_client +from mcp_use import MCPClient from utcp.data.utcp_manual import UtcpManual from utcp.data.call_template import CallTemplate from utcp.data.tool import Tool @@ -23,66 +21,165 @@ class McpCommunicationProtocol(CommunicationProtocol): """REQUIRED MCP transport implementation that connects to MCP servers via stdio or HTTP. - This implementation uses a session-per-operation approach where each operation - (register, call_tool) opens a fresh session, performs the operation, and closes. + This implementation uses MCPClient for simplified session management and reuses + sessions for better performance and efficiency. """ def __init__(self): self._oauth_tokens: Dict[str, Dict[str, Any]] = {} + self._mcp_client: Optional[MCPClient] = None - async def _list_tools_with_session(self, server_config: Dict[str, Any], auth: Optional[OAuth2Auth] = None): - # Create client streams based on transport type - if "command" in server_config and "args" in server_config: - params = StdioServerParameters(**server_config) - async with stdio_client(params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - tools_response = await session.list_tools() - return tools_response.tools - elif "url" in server_config: - # Get authentication token if OAuth2 is configured - auth_header = None - if auth and isinstance(auth, OAuth2Auth): - token = await self._handle_oauth2(auth) - auth_header = {"Authorization": f"Bearer {token}"} + async def _ensure_mcp_client(self, manual_call_template: 'McpCallTemplate'): + """Ensure MCPClient is initialized with the current configuration.""" + if self._mcp_client is None or self._mcp_client.config != manual_call_template.config.mcpServers: + # Create a new MCPClient with the server configuration + config = {"mcpServers": manual_call_template.config.mcpServers} + self._mcp_client = MCPClient.from_dict(config) + + async def _get_or_create_session(self, server_name: str, manual_call_template: 'McpCallTemplate'): + """Get an existing session or create a new one using MCPClient.""" + await self._ensure_mcp_client(manual_call_template) + + try: + # Try to get existing session + session = self._mcp_client.get_session(server_name) + logger.info(f"Reusing existing session for server: {server_name}") + return session + except ValueError: + # Session doesn't exist, create a new one + logger.info(f"Creating new session for server: {server_name}") + session = await self._mcp_client.create_session(server_name, auto_initialize=True) + return session + + async def _cleanup_session(self, server_name: str): + """Clean up a specific session.""" + if self._mcp_client: + await self._mcp_client.close_session(server_name) + logger.info(f"Cleaned up session for server: {server_name}") + + async def _cleanup_all_sessions(self): + """Clean up all active sessions.""" + if self._mcp_client: + await self._mcp_client.close_all_sessions() + logger.info("Cleaned up all sessions") + + async def _list_tools_with_session(self, server_name: str, manual_call_template: 'McpCallTemplate'): + """List tools using cached session when possible.""" + try: + session = await self._get_or_create_session(server_name, manual_call_template) + tools_response = await session.list_tools() + # Handle both direct list return and object with .tools attribute + if hasattr(tools_response, 'tools'): + return tools_response.tools + else: + return tools_response + except Exception as e: + # Check if this is a session-level error + error_message = str(e).lower() + session_errors = [ + "connection", "transport", "session", "protocol", "closed", + "disconnected", "timeout", "network", "broken pipe", "eof" + ] - async with streamablehttp_client(server_config["url"], auth=auth_header) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - tools_response = await session.list_tools() + is_session_error = any(error_keyword in error_message for error_keyword in session_errors) + + if is_session_error: + # Only restart session for connection/transport level issues + await self._cleanup_session(server_name) + logger.warning(f"Session-level error for list_tools, retrying with fresh session: {e}") + + # Retry with a fresh session + session = await self._get_or_create_session(server_name, manual_call_template) + tools_response = await session.list_tools() + # Handle both direct list return and object with .tools attribute + if hasattr(tools_response, 'tools'): return tools_response.tools - else: - raise ValueError(f"Unsupported MCP transport: {json.dumps(server_config)}") - - async def _call_tool_with_session(self, server_config: Dict[str, Any], tool_name: str, inputs: Dict[str, Any], auth: Optional[OAuth2Auth] = None): - if "command" in server_config and "args" in server_config: - params = StdioServerParameters(**server_config) - async with stdio_client(params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(tool_name, arguments=inputs) - return result - elif "url" in server_config: - # Get authentication token if OAuth2 is configured - auth_header = None - if auth and isinstance(auth, OAuth2Auth): - token = await self._handle_oauth2(auth) - auth_header = {"Authorization": f"Bearer {token}"} + else: + return tools_response + else: + # Protocol-level error, re-raise without session restart + logger.error(f"Protocol-level error for list_tools: {e}") + raise + + async def _list_resources_with_session(self, server_name: str, manual_call_template: 'McpCallTemplate'): + """List resources using cached session when possible.""" + try: + session = await self._get_or_create_session(server_name, manual_call_template) + resources_response = await session.list_resources() + # Handle both direct list return and object with .resources attribute + if hasattr(resources_response, 'resources'): + return resources_response.resources + else: + return resources_response + except Exception as e: + # If there's an error, clean up the potentially bad session and try once more + await self._cleanup_session(server_name) + logger.warning(f"Session failed for list_resources, retrying: {e}") + + # Retry with a fresh session + session = await self._get_or_create_session(server_name, manual_call_template) + resources_response = await session.list_resources() + # Handle both direct list return and object with .resources attribute + if hasattr(resources_response, 'resources'): + return resources_response.resources + else: + return resources_response + + async def _read_resource_with_session(self, server_name: str, manual_call_template: 'McpCallTemplate', resource_uri: str): + """Read a resource using cached session when possible.""" + try: + session = await self._get_or_create_session(server_name, manual_call_template) + result = await session.read_resource(resource_uri) + return result + except Exception as e: + # If there's an error, clean up the potentially bad session and try once more + await self._cleanup_session(server_name) + logger.warning(f"Session failed for read_resource '{resource_uri}', retrying: {e}") - async with streamablehttp_client( - url=server_config["url"], - headers=server_config.get("headers", None), - timeout=server_config.get("timeout", 30), - sse_read_timeout=server_config.get("sse_read_timeout", 60 * 5), - terminate_on_close=server_config.get("terminate_on_close", True), - auth=auth_header - ) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(tool_name, arguments=inputs) - return result - else: - raise ValueError(f"Unsupported MCP transport: {json.dumps(server_config)}") + # Retry with a fresh session + session = await self._get_or_create_session(server_name, manual_call_template) + result = await session.read_resource(resource_uri) + return result + + async def _handle_resource_call(self, resource_name: str, tool_call_template: 'McpCallTemplate') -> Any: + """Handle a resource call by finding and reading the resource from the appropriate server.""" + if not tool_call_template.config or not tool_call_template.config.mcpServers: + raise ValueError(f"No server configuration found for resource '{resource_name}'") + + # Try each server until we find one that has the resource + for server_name, server_config in tool_call_template.config.mcpServers.items(): + try: + logger.info(f"Attempting to find resource '{resource_name}' on server '{server_name}'") + + # List resources to find the one with matching name + resources = await self._list_resources_with_session(server_name, tool_call_template) + target_resource = None + for resource in resources: + if resource.name == resource_name: + target_resource = resource + break + + if target_resource is None: + logger.info(f"Resource '{resource_name}' not found in server '{server_name}'") + continue # Try next server + + # Read the resource + logger.info(f"Reading resource '{resource_name}' with URI '{target_resource.uri}' from server '{server_name}'") + result = await self._read_resource_with_session(server_name, tool_call_template, target_resource.uri) + + # Process the result + return result.model_dump() + except Exception as e: + logger.error(f"Error reading resource '{resource_name}' on server '{server_name}': {e}") + continue # Try next server + + raise ValueError(f"Resource '{resource_name}' not found in any configured server") + + async def _call_tool_with_session(self, server_name: str, manual_call_template: 'McpCallTemplate', tool_name: str, inputs: Dict[str, Any]): + """Call a tool using cached session when possible.""" + session = await self._get_or_create_session(server_name, manual_call_template) + result = await session.call_tool(tool_name, arguments=inputs) + return result async def register_manual(self, caller: 'UtcpClient', manual_call_template: CallTemplate) -> RegisterManualResult: """REQUIRED @@ -96,7 +193,7 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call for server_name, server_config in manual_call_template.config.mcpServers.items(): try: logger.info(f"Discovering tools for server '{server_name}' via {server_config}") - mcp_tools = await self._list_tools_with_session(server_config, auth=manual_call_template.auth) + mcp_tools = await self._list_tools_with_session(server_name, manual_call_template) logger.info(f"Discovered {len(mcp_tools)} tools for server '{server_name}'") for mcp_tool in mcp_tools: # Convert mcp.Tool to utcp.data.tool.Tool @@ -108,6 +205,40 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call tool_call_template=manual_call_template ) all_tools.append(utcp_tool) + + # Register resources as tools if enabled + if manual_call_template.register_resources_as_tools: + logger.info(f"Discovering resources for server '{server_name}' to register as tools") + try: + mcp_resources = await self._list_resources_with_session(server_name, manual_call_template) + logger.info(f"Discovered {len(mcp_resources)} resources for server '{server_name}'") + for mcp_resource in mcp_resources: + # Convert mcp.Resource to utcp.data.tool.Tool + # Create a tool that reads the resource when called + resource_tool = Tool( + name=f"resource_{mcp_resource.name}", + description=f"Read resource: {mcp_resource.description or mcp_resource.name}. URI: {mcp_resource.uri}", + input_schema={ + "type": "object", + "properties": {}, + "required": [] + }, + output_schema={ + "type": "object", + "properties": { + "contents": { + "type": "array", + "description": "Resource contents" + } + } + }, + tool_call_template=manual_call_template + ) + all_tools.append(resource_tool) + except Exception as resource_error: + logger.warning(f"Failed to discover resources for server '{server_name}': {resource_error}") + # Don't add this to errors since resources are optional + except Exception as e: logger.error(f"Failed to discover tools for server '{server_name}': {e}") errors.append(f"Failed to discover tools for server '{server_name}': {e}") @@ -129,13 +260,21 @@ async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[ if not tool_call_template.config or not tool_call_template.config.mcpServers: raise ValueError(f"No server configuration found for tool '{tool_name}'") + if "." in tool_name: + tool_name = tool_name.split(".", 1)[1] + + # Check if this is a resource call (tools created from resources have "resource_" prefix) + if tool_name.startswith("resource_"): + resource_name = tool_name[9:] # Remove "resource_" prefix + return await self._handle_resource_call(resource_name, tool_call_template) + # Try each server until we find one that has the tool for server_name, server_config in tool_call_template.config.mcpServers.items(): try: logger.info(f"Attempting to call tool '{tool_name}' on server '{server_name}'") # First check if this server has the tool - tools = await self._list_tools_with_session(server_config, auth=tool_call_template.auth) + tools = await self._list_tools_with_session(server_name, tool_call_template) tool_names = [tool.name for tool in tools] if tool_name not in tool_names: @@ -143,13 +282,13 @@ async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[ continue # Try next server # Call the tool - result = await self._call_tool_with_session(server_config, tool_name, tool_args, auth=tool_call_template.auth) + result = await self._call_tool_with_session(server_name, tool_call_template, tool_name, tool_args) # Process the result return self._process_tool_result(result, tool_name) except Exception as e: logger.error(f"Error calling tool '{tool_name}' on server '{server_name}': {e}") - continue # Try next server + raise e raise ValueError(f"Tool '{tool_name}' not found in any configured server") @@ -235,9 +374,25 @@ def _parse_text_content(self, text: str) -> Any: return text async def deregister_manual(self, caller: 'UtcpClient', manual_call_template: CallTemplate) -> None: - """Deregister an MCP manual. This is a no-op in session-per-operation mode.""" - logger.info(f"Deregistering manual '{manual_call_template.name}' (no-op in session-per-operation mode)") - pass + """Deregister an MCP manual and clean up associated sessions.""" + if not isinstance(manual_call_template, McpCallTemplate): + logger.info(f"Deregistering manual '{manual_call_template.name}' - not an MCP template") + return + + logger.info(f"Deregistering manual '{manual_call_template.name}' and cleaning up sessions") + + # Clean up sessions for all servers in this manual + if manual_call_template.config and manual_call_template.config.mcpServers: + for server_name, server_config in manual_call_template.config.mcpServers.items(): + await self._cleanup_session(server_name) + logger.info(f"Cleaned up session for server '{server_name}'") + + async def close(self) -> None: + """Close all active sessions and clean up resources.""" + logger.info("Closing MCP communication protocol and cleaning up all sessions") + await self._cleanup_all_sessions() + self._session_locks.clear() + logger.info("MCP communication protocol closed successfully") async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: """Handles OAuth2 client credentials flow, trying both body and auth header methods.""" diff --git a/plugins/communication_protocols/mcp/tests/mock_mcp_server.py b/plugins/communication_protocols/mcp/tests/mock_mcp_server.py index d7c2166..61ec8c0 100644 --- a/plugins/communication_protocols/mcp/tests/mock_mcp_server.py +++ b/plugins/communication_protocols/mcp/tests/mock_mcp_server.py @@ -39,6 +39,21 @@ def add_numbers(a: int, b: int) -> int: return a + b +# Add some test resources +@mcp.resource("file://test_document.txt") +def get_test_document(): + """A test document resource""" + return "This is a test document with some content for testing MCP resources." + + +@mcp.resource("file://config.json") +def get_config(): + """A test configuration file""" + return '{"name": "test_config", "version": "1.0", "debug": true}' + + # Start the server when this script is run directly if __name__ == "__main__": - mcp.run() \ No newline at end of file + def main(): + mcp.run() + main() \ No newline at end of file diff --git a/plugins/communication_protocols/mcp/tests/test_mcp_transport.py b/plugins/communication_protocols/mcp/tests/test_mcp_transport.py index 62e216e..4af2691 100644 --- a/plugins/communication_protocols/mcp/tests/test_mcp_transport.py +++ b/plugins/communication_protocols/mcp/tests/test_mcp_transport.py @@ -24,6 +24,22 @@ def mcp_manual() -> McpCallTemplate: ) +@pytest_asyncio.fixture +def mcp_manual_with_resources() -> McpCallTemplate: + """Provides an McpCallTemplate with resources enabled.""" + server_path = os.path.join(os.path.dirname(__file__), "mock_mcp_server.py") + server_config = { + "command": sys.executable, + "args": [server_path], + } + return McpCallTemplate( + name="mock_mcp_manual_with_resources", + call_template_type="mcp", + config=McpConfig(mcpServers={SERVER_NAME: server_config}), + register_resources_as_tools=True + ) + + @pytest_asyncio.fixture async def transport() -> McpCommunicationProtocol: """Provides a clean McpCommunicationProtocol instance.""" @@ -118,3 +134,113 @@ async def test_deregister_manual(transport: McpCommunicationProtocol, mcp_manual result = await transport.call_tool(None, "echo", {"message": "test"}, mcp_manual) assert result == {"reply": "you said: test"} + + +@pytest.mark.asyncio +async def test_register_resources_as_tools_disabled(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate): + """Verify that resources are NOT registered as tools when flag is False (default).""" + register_result = await transport.register_manual(None, mcp_manual) + assert register_result.success + assert len(register_result.manual.tools) == 4 # Only the regular tools + + # Check that no resource tools are present + tool_names = [tool.name for tool in register_result.manual.tools] + resource_tools = [name for name in tool_names if name.startswith("resource_")] + assert len(resource_tools) == 0 + + +@pytest.mark.asyncio +async def test_register_resources_as_tools_enabled(transport: McpCommunicationProtocol, mcp_manual_with_resources: McpCallTemplate): + """Verify that resources are registered as tools when flag is True.""" + register_result = await transport.register_manual(None, mcp_manual_with_resources) + assert register_result.success + + # Should have 4 regular tools + 2 resource tools = 6 total + assert len(register_result.manual.tools) >= 6 + + # Check that resource tools are present + tool_names = [tool.name for tool in register_result.manual.tools] + resource_tools = [name for name in tool_names if name.startswith("resource_")] + assert len(resource_tools) == 2 + assert "resource_get_test_document" in resource_tools + assert "resource_get_config" in resource_tools + + # Check resource tool properties + test_doc_tool = next((tool for tool in register_result.manual.tools if tool.name == "resource_get_test_document"), None) + assert test_doc_tool is not None + assert "Read resource:" in test_doc_tool.description + assert "file://test_document.txt" in test_doc_tool.description + + +@pytest.mark.asyncio +async def test_call_resource_tool(transport: McpCommunicationProtocol, mcp_manual_with_resources: McpCallTemplate): + """Verify that calling a resource tool returns the resource content.""" + # Register the manual with resources + await transport.register_manual(None, mcp_manual_with_resources) + + # Call the test document resource + result = await transport.call_tool(None, "resource_get_test_document", {}, mcp_manual_with_resources) + + # Check that we get the resource content + assert isinstance(result, dict) + assert "contents" in result + contents = result["contents"] + + # The content should contain the test document text + found_test_content = False + for content_item in contents: + if isinstance(content_item, dict) and "text" in content_item: + if "This is a test document" in content_item["text"]: + found_test_content = True + break + elif isinstance(content_item, str) and "This is a test document" in content_item: + found_test_content = True + break + + assert found_test_content, f"Expected test document content not found in: {contents}" + + +@pytest.mark.asyncio +async def test_call_resource_tool_json_content(transport: McpCommunicationProtocol, mcp_manual_with_resources: McpCallTemplate): + """Verify that calling a JSON resource tool returns the structured content.""" + # Register the manual with resources + await transport.register_manual(None, mcp_manual_with_resources) + + # Call the config.json resource + result = await transport.call_tool(None, "resource_get_config", {}, mcp_manual_with_resources) + + # Check that we get the resource content + assert isinstance(result, dict) + assert "contents" in result + contents = result["contents"] + + # The content should contain the JSON config + found_json_content = False + for content_item in contents: + if isinstance(content_item, dict) and "text" in content_item: + if "test_config" in content_item["text"]: + found_json_content = True + break + elif isinstance(content_item, str) and "test_config" in content_item: + found_json_content = True + break + + assert found_json_content, f"Expected JSON content not found in: {contents}" + + +@pytest.mark.asyncio +async def test_call_nonexistent_resource_tool(transport: McpCommunicationProtocol, mcp_manual_with_resources: McpCallTemplate): + """Verify that calling a non-existent resource tool raises an error.""" + with pytest.raises(ValueError, match="Resource 'nonexistent' not found in any configured server"): + await transport.call_tool(None, "resource_nonexistent", {}, mcp_manual_with_resources) + + +@pytest.mark.asyncio +async def test_resource_tool_without_registration(transport: McpCommunicationProtocol, mcp_manual_with_resources: McpCallTemplate): + """Verify that resource tools work even without prior registration.""" + # Don't register the manual first - test direct call + result = await transport.call_tool(None, "resource_get_test_document", {}, mcp_manual_with_resources) + + # Should still work and return content + assert isinstance(result, dict) + assert "contents" in result From 51fe2dadd1cbe82fdd4c97a0c155c15896e1acd6 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:38:18 +0200 Subject: [PATCH 02/21] update minimum python version --- plugins/communication_protocols/mcp/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/communication_protocols/mcp/pyproject.toml b/plugins/communication_protocols/mcp/pyproject.toml index f4aca35..8848c8e 100644 --- a/plugins/communication_protocols/mcp/pyproject.toml +++ b/plugins/communication_protocols/mcp/pyproject.toml @@ -10,7 +10,7 @@ authors = [ ] description = "UTCP communication protocol plugin for interoperability with the Model Context Protocol (MCP)." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "pydantic>=2.0", "mcp>=1.12", From 75e3a22df781c9f33aae87cad46a7abb178fe238 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:41:38 +0200 Subject: [PATCH 03/21] Update test.yml --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f73191..9394330 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 From 2b5124801a9c0d8130209494c8c2f3e363d57910 Mon Sep 17 00:00:00 2001 From: Andrei Ghiurtu Date: Sat, 30 Aug 2025 14:21:39 +0300 Subject: [PATCH 04/21] core[dev] install fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7b115e7..5c02e85 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ git clone https://github.com/universal-tool-calling-protocol/python-utcp.git cd python-utcp # Install the core package in editable mode with dev dependencies -pip install -e core[dev] +pip install -e "core[dev]" # Install a specific protocol plugin in editable mode pip install -e plugins/communication_protocols/http From 1dc0a0f744666273c693473d0818eb0a8462019c Mon Sep 17 00:00:00 2001 From: Andrei Ghiurtu Date: Sun, 31 Aug 2025 20:33:20 +0300 Subject: [PATCH 05/21] Fix plugin logs not showing and mcp register tools --- .../utcp_client_implementation.py | 12 ++- .../utcp_cli/cli_communication_protocol.py | 7 ++ .../utcp_gql/gql_communication_protocol.py | 8 ++ .../utcp_http/http_communication_protocol.py | 7 ++ .../utcp_http/sse_communication_protocol.py | 7 ++ .../streamable_http_communication_protocol.py | 7 ++ .../utcp_mcp/mcp_communication_protocol.py | 88 ++++++++++++------- .../utcp_socket/tcp_communication_protocol.py | 7 ++ .../utcp_text/text_communication_protocol.py | 8 ++ 9 files changed, 116 insertions(+), 35 deletions(-) diff --git a/core/src/utcp/implementations/utcp_client_implementation.py b/core/src/utcp/implementations/utcp_client_implementation.py index 555ba2e..53e6b71 100644 --- a/core/src/utcp/implementations/utcp_client_implementation.py +++ b/core/src/utcp/implementations/utcp_client_implementation.py @@ -110,11 +110,21 @@ async def register_manual(self, manual_call_template: CallTemplate) -> RegisterM raise ValueError(f"No registered communication protocol of type {manual_call_template.call_template_type} found, available types: {CommunicationProtocol.communication_protocols.keys()}") result = await CommunicationProtocol.communication_protocols[manual_call_template.call_template_type].register_manual(self, manual_call_template) - + if result.success: + final_tools = [] for tool in result.manual.tools: if not tool.name.startswith(manual_call_template.name + "."): tool.name = manual_call_template.name + "." + tool.name + + if tool.tool_call_template.call_template_type != "mcp": + final_tools.append(tool) + else: + mcp_result = await CommunicationProtocol.communication_protocols["mcp"].register_manual(self, tool.tool_call_template) + if mcp_result.success: + final_tools.extend(mcp_result.manual.tools) + + result.manual.tools = final_tools await self.config.tool_repository.save_manual(result.manual_call_template, result.manual) return result diff --git a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py index 148e261..cc5d172 100644 --- a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py +++ b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py @@ -23,6 +23,7 @@ import json import os import shlex +import sys from typing import Dict, Any, List, Optional, Callable, AsyncGenerator from utcp.interfaces.communication_protocol import CommunicationProtocol @@ -35,6 +36,12 @@ logger = logging.getLogger(__name__) +if not logger.handlers: # Only add default handler if user didn't configure logging + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + class CliCommunicationProtocol(CommunicationProtocol): """REQUIRED diff --git a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py index 523a97c..771aad4 100644 --- a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py +++ b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py @@ -1,3 +1,4 @@ +import sys from typing import Dict, Any, List, Optional, Callable import aiohttp import asyncio @@ -12,6 +13,13 @@ logger = logging.getLogger(__name__) +if not logger.handlers: # Only add default handler if user didn't configure logging + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + class GraphQLClientTransport(ClientTransportInterface): """ Simple, robust, production-ready GraphQL transport using gql. diff --git a/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py index 68de65e..9be24fc 100644 --- a/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py @@ -12,6 +12,7 @@ - Request/response handling with proper error management """ +import sys from typing import Dict, Any, List, Optional, Callable, AsyncGenerator import aiohttp import json @@ -36,6 +37,12 @@ logger = logging.getLogger(__name__) +if not logger.handlers: # Only add default handler if user didn't configure logging + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + class HttpCommunicationProtocol(CommunicationProtocol): """REQUIRED HTTP communication protocol implementation for UTCP client. diff --git a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py index b3fa8d5..e4272a0 100644 --- a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py @@ -1,3 +1,4 @@ +import sys from typing import Dict, Any, List, Optional, Callable, AsyncIterator, AsyncGenerator import aiohttp import json @@ -21,6 +22,12 @@ logger = logging.getLogger(__name__) +if not logger.handlers: # Only add default handler if user didn't configure logging + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + class SseCommunicationProtocol(CommunicationProtocol): """REQUIRED SSE communication protocol implementation for UTCP client. diff --git a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py index 947470f..df8e86c 100644 --- a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py @@ -1,3 +1,4 @@ +import sys from typing import Dict, Any, List, Optional, Callable, AsyncIterator, Tuple, AsyncGenerator import aiohttp import json @@ -18,6 +19,12 @@ logger = logging.getLogger(__name__) +if not logger.handlers: # Only add default handler if user didn't configure logging + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + class StreamableHttpCommunicationProtocol(CommunicationProtocol): """REQUIRED Streamable HTTP communication protocol implementation for UTCP client. diff --git a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py index 1bd238d..1ae591d 100644 --- a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py +++ b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py @@ -1,3 +1,4 @@ +import sys from typing import Any, Dict, Optional, AsyncGenerator, TYPE_CHECKING import json @@ -17,6 +18,13 @@ logger = logging.getLogger(__name__) +if not logger.handlers: # Only add default handler if user didn't configure logging + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + class McpCommunicationProtocol(CommunicationProtocol): """REQUIRED MCP transport implementation that connects to MCP servers via stdio or HTTP. @@ -28,6 +36,18 @@ class McpCommunicationProtocol(CommunicationProtocol): def __init__(self): self._oauth_tokens: Dict[str, Dict[str, Any]] = {} self._mcp_client: Optional[MCPClient] = None + + def _log_info(self, message: str): + """Log informational messages.""" + logger.info(f"[McpCommunicationProtocol] {message}") + + def _log_warning(self, message: str): + """Log informational messages.""" + logger.warning(f"[McpCommunicationProtocol] {message}") + + def _log_error(self, message: str): + """Log error messages.""" + logger.error(f"[McpCommunicationProtocol] {message}") async def _ensure_mcp_client(self, manual_call_template: 'McpCallTemplate'): """Ensure MCPClient is initialized with the current configuration.""" @@ -43,11 +63,11 @@ async def _get_or_create_session(self, server_name: str, manual_call_template: ' try: # Try to get existing session session = self._mcp_client.get_session(server_name) - logger.info(f"Reusing existing session for server: {server_name}") + self._log_info(f"Reusing existing session for server: {server_name}") return session except ValueError: # Session doesn't exist, create a new one - logger.info(f"Creating new session for server: {server_name}") + self._log_info(f"Creating new session for server: {server_name}") session = await self._mcp_client.create_session(server_name, auto_initialize=True) return session @@ -55,13 +75,13 @@ async def _cleanup_session(self, server_name: str): """Clean up a specific session.""" if self._mcp_client: await self._mcp_client.close_session(server_name) - logger.info(f"Cleaned up session for server: {server_name}") + self._log_info(f"Cleaned up session for server: {server_name}") async def _cleanup_all_sessions(self): """Clean up all active sessions.""" if self._mcp_client: await self._mcp_client.close_all_sessions() - logger.info("Cleaned up all sessions") + self._log_info("Cleaned up all sessions") async def _list_tools_with_session(self, server_name: str, manual_call_template: 'McpCallTemplate'): """List tools using cached session when possible.""" @@ -86,7 +106,7 @@ async def _list_tools_with_session(self, server_name: str, manual_call_template: if is_session_error: # Only restart session for connection/transport level issues await self._cleanup_session(server_name) - logger.warning(f"Session-level error for list_tools, retrying with fresh session: {e}") + self._log_warning(f"Session-level error for list_tools, retrying with fresh session: {e}") # Retry with a fresh session session = await self._get_or_create_session(server_name, manual_call_template) @@ -98,7 +118,7 @@ async def _list_tools_with_session(self, server_name: str, manual_call_template: return tools_response else: # Protocol-level error, re-raise without session restart - logger.error(f"Protocol-level error for list_tools: {e}") + self._log_error(f"Protocol-level error for list_tools: {e}") raise async def _list_resources_with_session(self, server_name: str, manual_call_template: 'McpCallTemplate'): @@ -114,7 +134,7 @@ async def _list_resources_with_session(self, server_name: str, manual_call_templ except Exception as e: # If there's an error, clean up the potentially bad session and try once more await self._cleanup_session(server_name) - logger.warning(f"Session failed for list_resources, retrying: {e}") + self._log_warning(f"Session failed for list_resources, retrying: {e}") # Retry with a fresh session session = await self._get_or_create_session(server_name, manual_call_template) @@ -134,7 +154,7 @@ async def _read_resource_with_session(self, server_name: str, manual_call_templa except Exception as e: # If there's an error, clean up the potentially bad session and try once more await self._cleanup_session(server_name) - logger.warning(f"Session failed for read_resource '{resource_uri}', retrying: {e}") + self._log_warning(f"Session failed for read_resource '{resource_uri}', retrying: {e}") # Retry with a fresh session session = await self._get_or_create_session(server_name, manual_call_template) @@ -149,7 +169,7 @@ async def _handle_resource_call(self, resource_name: str, tool_call_template: 'M # Try each server until we find one that has the resource for server_name, server_config in tool_call_template.config.mcpServers.items(): try: - logger.info(f"Attempting to find resource '{resource_name}' on server '{server_name}'") + self._log_info(f"Attempting to find resource '{resource_name}' on server '{server_name}'") # List resources to find the one with matching name resources = await self._list_resources_with_session(server_name, tool_call_template) @@ -160,17 +180,17 @@ async def _handle_resource_call(self, resource_name: str, tool_call_template: 'M break if target_resource is None: - logger.info(f"Resource '{resource_name}' not found in server '{server_name}'") + self._log_info(f"Resource '{resource_name}' not found in server '{server_name}'") continue # Try next server # Read the resource - logger.info(f"Reading resource '{resource_name}' with URI '{target_resource.uri}' from server '{server_name}'") + self._log_info(f"Reading resource '{resource_name}' with URI '{target_resource.uri}' from server '{server_name}'") result = await self._read_resource_with_session(server_name, tool_call_template, target_resource.uri) # Process the result return result.model_dump() except Exception as e: - logger.error(f"Error reading resource '{resource_name}' on server '{server_name}': {e}") + self._log_error(f"Error reading resource '{resource_name}' on server '{server_name}': {e}") continue # Try next server raise ValueError(f"Resource '{resource_name}' not found in any configured server") @@ -192,9 +212,9 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call if manual_call_template.config and manual_call_template.config.mcpServers: for server_name, server_config in manual_call_template.config.mcpServers.items(): try: - logger.info(f"Discovering tools for server '{server_name}' via {server_config}") + self._log_info(f"Discovering tools for server '{server_name}' via {server_config}") mcp_tools = await self._list_tools_with_session(server_name, manual_call_template) - logger.info(f"Discovered {len(mcp_tools)} tools for server '{server_name}'") + self._log_info(f"Discovered {len(mcp_tools)} tools for server '{server_name}'") for mcp_tool in mcp_tools: # Convert mcp.Tool to utcp.data.tool.Tool utcp_tool = Tool( @@ -208,10 +228,10 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call # Register resources as tools if enabled if manual_call_template.register_resources_as_tools: - logger.info(f"Discovering resources for server '{server_name}' to register as tools") + self._log_info(f"Discovering resources for server '{server_name}' to register as tools") try: mcp_resources = await self._list_resources_with_session(server_name, manual_call_template) - logger.info(f"Discovered {len(mcp_resources)} resources for server '{server_name}'") + self._log_info(f"Discovered {len(mcp_resources)} resources for server '{server_name}'") for mcp_resource in mcp_resources: # Convert mcp.Resource to utcp.data.tool.Tool # Create a tool that reads the resource when called @@ -236,11 +256,11 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call ) all_tools.append(resource_tool) except Exception as resource_error: - logger.warning(f"Failed to discover resources for server '{server_name}': {resource_error}") + self._log_warning(f"Failed to discover resources for server '{server_name}': {resource_error}") # Don't add this to errors since resources are optional except Exception as e: - logger.error(f"Failed to discover tools for server '{server_name}': {e}") + self._log_error(f"Failed to discover tools for server '{server_name}': {e}") errors.append(f"Failed to discover tools for server '{server_name}': {e}") return RegisterManualResult( manual_call_template=manual_call_template, @@ -271,14 +291,14 @@ async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[ # Try each server until we find one that has the tool for server_name, server_config in tool_call_template.config.mcpServers.items(): try: - logger.info(f"Attempting to call tool '{tool_name}' on server '{server_name}'") + self._log_info(f"Attempting to call tool '{tool_name}' on server '{server_name}'") # First check if this server has the tool tools = await self._list_tools_with_session(server_name, tool_call_template) tool_names = [tool.name for tool in tools] if tool_name not in tool_names: - logger.info(f"Tool '{tool_name}' not found in server '{server_name}'") + self._log_info(f"Tool '{tool_name}' not found in server '{server_name}'") continue # Try next server # Call the tool @@ -287,7 +307,7 @@ async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[ # Process the result return self._process_tool_result(result, tool_name) except Exception as e: - logger.error(f"Error calling tool '{tool_name}' on server '{server_name}': {e}") + self._log_error(f"Error calling tool '{tool_name}' on server '{server_name}': {e}") raise e raise ValueError(f"Tool '{tool_name}' not found in any configured server") @@ -298,21 +318,21 @@ async def call_tool_streaming(self, caller: 'UtcpClient', tool_name: str, tool_a yield self.call_tool(caller, tool_name, tool_args, tool_call_template) def _process_tool_result(self, result, tool_name: str) -> Any: - logger.info(f"Processing tool result for '{tool_name}', type: {type(result)}") + self._log_info(f"Processing tool result for '{tool_name}', type: {type(result)}") # Check for structured output first if hasattr(result, 'structured_output'): - logger.info(f"Found structured_output: {result.structured_output}") + self._log_info(f"Found structured_output: {result.structured_output}") return result.structured_output # Process content if available if hasattr(result, 'content'): content = result.content - logger.info(f"Content type: {type(content)}") + self._log_info(f"Content type: {type(content)}") # Handle list content if isinstance(content, list): - logger.info(f"Content is a list with {len(content)} items") + self._log_info(f"Content is a list with {len(content)} items") if not content: return [] @@ -376,23 +396,23 @@ def _parse_text_content(self, text: str) -> Any: async def deregister_manual(self, caller: 'UtcpClient', manual_call_template: CallTemplate) -> None: """Deregister an MCP manual and clean up associated sessions.""" if not isinstance(manual_call_template, McpCallTemplate): - logger.info(f"Deregistering manual '{manual_call_template.name}' - not an MCP template") + self._log_info(f"Deregistering manual '{manual_call_template.name}' - not an MCP template") return - logger.info(f"Deregistering manual '{manual_call_template.name}' and cleaning up sessions") + self._log_info(f"Deregistering manual '{manual_call_template.name}' and cleaning up sessions") # Clean up sessions for all servers in this manual if manual_call_template.config and manual_call_template.config.mcpServers: for server_name, server_config in manual_call_template.config.mcpServers.items(): await self._cleanup_session(server_name) - logger.info(f"Cleaned up session for server '{server_name}'") + self._log_info(f"Cleaned up session for server '{server_name}'") async def close(self) -> None: """Close all active sessions and clean up resources.""" - logger.info("Closing MCP communication protocol and cleaning up all sessions") + self._log_info("Closing MCP communication protocol and cleaning up all sessions") await self._cleanup_all_sessions() self._session_locks.clear() - logger.info("MCP communication protocol closed successfully") + self._log_info("MCP communication protocol closed successfully") async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: """Handles OAuth2 client credentials flow, trying both body and auth header methods.""" @@ -405,7 +425,7 @@ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: async with aiohttp.ClientSession() as session: # Method 1: Send credentials in the request body try: - logger.info(f"Attempting OAuth2 token fetch for '{client_id}' with credentials in body.") + self._log_info(f"Attempting OAuth2 token fetch for '{client_id}' with credentials in body.") body_data = { 'grant_type': 'client_credentials', 'client_id': client_id, @@ -418,11 +438,11 @@ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: self._oauth_tokens[client_id] = token_response return token_response["access_token"] except aiohttp.ClientError as e: - logger.error(f"OAuth2 with credentials in body failed: {e}. Trying Basic Auth header.") + self._log_error(f"OAuth2 with credentials in body failed: {e}. Trying Basic Auth header.") # Method 2: Send credentials as Basic Auth header try: - logger.info(f"Attempting OAuth2 token fetch for '{client_id}' with Basic Auth header.") + self._log_info(f"Attempting OAuth2 token fetch for '{client_id}' with Basic Auth header.") header_auth = AiohttpBasicAuth(client_id, auth_details.client_secret) header_data = { 'grant_type': 'client_credentials', @@ -434,5 +454,5 @@ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: self._oauth_tokens[client_id] = token_response return token_response["access_token"] except aiohttp.ClientError as e: - logger.error(f"OAuth2 with Basic Auth header also failed: {e}") + self._log_error(f"OAuth2 with Basic Auth header also failed: {e}") raise e diff --git a/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py b/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py index 95d0e5f..9db2c13 100644 --- a/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py +++ b/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py @@ -7,6 +7,7 @@ import json import socket import struct +import sys from typing import Dict, Any, List, Optional, Callable, Union from utcp.client.client_transport_interface import ClientTransportInterface @@ -16,6 +17,12 @@ logger = logging.getLogger(__name__) +if not logger.handlers: # Only add default handler if user didn't configure logging + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + class TCPTransport(ClientTransportInterface): """Transport implementation for TCP-based tool providers. diff --git a/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py b/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py index 4a66b56..aaa156b 100644 --- a/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py +++ b/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py @@ -5,6 +5,7 @@ tools. It does not maintain any persistent connections. """ import json +import sys import yaml import aiofiles from pathlib import Path @@ -25,6 +26,13 @@ logger = logging.getLogger(__name__) +if not logger.handlers: # Only add default handler if user didn't configure logging + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + class TextCommunicationProtocol(CommunicationProtocol): """REQUIRED Communication protocol for file-based UTCP manuals and tools.""" From 5c145a39ad1a8ee8605992cb6a522b6cb5ed0b2e Mon Sep 17 00:00:00 2001 From: Andrei Ghiurtu Date: Sun, 31 Aug 2025 20:37:39 +0300 Subject: [PATCH 06/21] Add tool name --- core/src/utcp/implementations/utcp_client_implementation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/src/utcp/implementations/utcp_client_implementation.py b/core/src/utcp/implementations/utcp_client_implementation.py index 53e6b71..79ec753 100644 --- a/core/src/utcp/implementations/utcp_client_implementation.py +++ b/core/src/utcp/implementations/utcp_client_implementation.py @@ -122,6 +122,9 @@ async def register_manual(self, manual_call_template: CallTemplate) -> RegisterM else: mcp_result = await CommunicationProtocol.communication_protocols["mcp"].register_manual(self, tool.tool_call_template) if mcp_result.success: + for mcp_tool in mcp_result.manual.tools: + if not mcp_tool.name.startswith(tool.name + "."): + mcp_tool.name = tool.name + "." + mcp_tool.name final_tools.extend(mcp_result.manual.tools) result.manual.tools = final_tools From 133c1878acdf1af742d9dc84d3f21c41dee59e4f Mon Sep 17 00:00:00 2001 From: Andrei Ghiurtu Date: Sun, 31 Aug 2025 21:56:58 +0300 Subject: [PATCH 07/21] Performance optimisation --- .../utcp_mcp/mcp_communication_protocol.py | 163 ++++++++++++------ 1 file changed, 109 insertions(+), 54 deletions(-) diff --git a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py index 1ae591d..4619b2d 100644 --- a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py +++ b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py @@ -1,5 +1,5 @@ import sys -from typing import Any, Dict, Optional, AsyncGenerator, TYPE_CHECKING +from typing import Any, Dict, Optional, AsyncGenerator, TYPE_CHECKING, Tuple import json from mcp_use import MCPClient @@ -42,7 +42,7 @@ def _log_info(self, message: str): logger.info(f"[McpCommunicationProtocol] {message}") def _log_warning(self, message: str): - """Log informational messages.""" + """Log warning messages.""" logger.warning(f"[McpCommunicationProtocol] {message}") def _log_error(self, message: str): @@ -161,40 +161,6 @@ async def _read_resource_with_session(self, server_name: str, manual_call_templa result = await session.read_resource(resource_uri) return result - async def _handle_resource_call(self, resource_name: str, tool_call_template: 'McpCallTemplate') -> Any: - """Handle a resource call by finding and reading the resource from the appropriate server.""" - if not tool_call_template.config or not tool_call_template.config.mcpServers: - raise ValueError(f"No server configuration found for resource '{resource_name}'") - - # Try each server until we find one that has the resource - for server_name, server_config in tool_call_template.config.mcpServers.items(): - try: - self._log_info(f"Attempting to find resource '{resource_name}' on server '{server_name}'") - - # List resources to find the one with matching name - resources = await self._list_resources_with_session(server_name, tool_call_template) - target_resource = None - for resource in resources: - if resource.name == resource_name: - target_resource = resource - break - - if target_resource is None: - self._log_info(f"Resource '{resource_name}' not found in server '{server_name}'") - continue # Try next server - - # Read the resource - self._log_info(f"Reading resource '{resource_name}' with URI '{target_resource.uri}' from server '{server_name}'") - result = await self._read_resource_with_session(server_name, tool_call_template, target_resource.uri) - - # Process the result - return result.model_dump() - except Exception as e: - self._log_error(f"Error reading resource '{resource_name}' on server '{server_name}': {e}") - continue # Try next server - - raise ValueError(f"Resource '{resource_name}' not found in any configured server") - async def _call_tool_with_session(self, server_name: str, manual_call_template: 'McpCallTemplate', tool_name: str, inputs: Dict[str, Any]): """Call a tool using cached session when possible.""" session = await self._get_or_create_session(server_name, manual_call_template) @@ -280,28 +246,30 @@ async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[ if not tool_call_template.config or not tool_call_template.config.mcpServers: raise ValueError(f"No server configuration found for tool '{tool_name}'") - if "." in tool_name: - tool_name = tool_name.split(".", 1)[1] - - # Check if this is a resource call (tools created from resources have "resource_" prefix) - if tool_name.startswith("resource_"): - resource_name = tool_name[9:] # Remove "resource_" prefix - return await self._handle_resource_call(resource_name, tool_call_template) - - # Try each server until we find one that has the tool - for server_name, server_config in tool_call_template.config.mcpServers.items(): + parse_result = await self._parse_tool_name(tool_name, tool_call_template) + + if parse_result.is_resource: + resource_name = parse_result.name + server_name = parse_result.server_name + target_resource = parse_result.target_resource + try: - self._log_info(f"Attempting to call tool '{tool_name}' on server '{server_name}'") - - # First check if this server has the tool - tools = await self._list_tools_with_session(server_name, tool_call_template) - tool_names = [tool.name for tool in tools] + # Read the resource + self._log_info(f"Reading resource '{resource_name}' with URI '{target_resource.uri}' from server '{server_name}'") + result = await self._read_resource_with_session(server_name, tool_call_template, target_resource.uri) - if tool_name not in tool_names: - self._log_info(f"Tool '{tool_name}' not found in server '{server_name}'") - continue # Try next server + # Process the result + return result.model_dump() + except Exception as e: + self._log_error(f"Error reading resource '{resource_name}' on server '{server_name}': {e}") + raise e + else: + tool_name = parse_result.name + server_name = parse_result.server_name + try: # Call the tool + self._log_info(f"Call tool '{tool_name}' from server '{server_name}'") result = await self._call_tool_with_session(server_name, tool_call_template, tool_name, tool_args) # Process the result @@ -309,8 +277,95 @@ async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[ except Exception as e: self._log_error(f"Error calling tool '{tool_name}' on server '{server_name}': {e}") raise e + + class _ParseToolResult: + def __init__(self, manual_name: Optional[str], server_name: str, name: str, is_resource: bool, target_resource: Any): + self.manual_name = manual_name + self.server_name = server_name + self.name = name + self.is_resource = is_resource + self.target_resource = target_resource + + async def _parse_tool_name(self, tool_name: str, tool_call_template: McpCallTemplate) -> _ParseToolResult: + def normalize(val): + if isinstance(val, tuple): + return val + return (val, None) + + if "." not in tool_name: + is_resource, name = self._is_resource(tool_name) + server_name, target_resource = normalize(await self._get_tool_server(name, tool_call_template) if not is_resource else await self._get_resource_server(name, tool_call_template)) + return McpCommunicationProtocol._ParseToolResult(None, server_name, name, is_resource, target_resource) + + split = tool_name.split(".", 1) + manual_name = split[0] + tool_name = split[1] + + if "." not in tool_name: + is_resource, name = self._is_resource(tool_name) + server_name, target_resource = normalize(await self._get_tool_server(name, tool_call_template) if not is_resource else await self._get_resource_server(name, tool_call_template)) + return McpCommunicationProtocol._ParseToolResult(manual_name, server_name, name, is_resource, target_resource) + + split = tool_name.split(".", 1) + server_name = split[0] + tool_name = split[1] + + is_resource, name = self._is_resource(tool_name) + server_name, target_resource = normalize(await self._get_tool_server(name, tool_call_template) if not is_resource else await self._get_resource_server(name, tool_call_template)) + return McpCommunicationProtocol._ParseToolResult(manual_name, server_name, name, is_resource, target_resource) + + def _is_resource(self, tool_name) -> Tuple[bool, str]: + resource_prefix = "resource_" + resource_length = len(resource_prefix) + + if tool_name.startswith(resource_prefix): + return True, tool_name[resource_length:] + + return False, tool_name + + async def _get_tool_server(self, tool_name: str, tool_call_template: McpCallTemplate) -> str: + if "." in tool_name: + split = tool_name.split(".", 1) + server_name = split[0] + tool_name = split[1] + + return server_name + + # Try each server until we find one that has the tool + for server_name, server_config in tool_call_template.config.mcpServers.items(): + self._log_info(f"Attempting to call tool '{tool_name}' on server '{server_name}'") + + # First check if this server has the tool + tools = await self._list_tools_with_session(server_name, tool_call_template) + tool_names = [tool.name for tool in tools] + + if tool_name not in tool_names: + self._log_info(f"Tool '{tool_name}' not found in server '{server_name}'") + continue # Try next server + + return server_name raise ValueError(f"Tool '{tool_name}' not found in any configured server") + + async def _get_resource_server(self, resource_name: str, tool_call_template: McpCallTemplate) -> Tuple[str, Any]: + for server_name, server_config in tool_call_template.config.mcpServers.items(): + self._log_info(f"Attempting to find resource '{resource_name}' on server '{server_name}'") + + # List resources to find the one with matching name + resources = await self._list_resources_with_session(server_name, tool_call_template) + target_resource = None + for resource in resources: + if resource.name == resource_name: + target_resource = resource + break + + if target_resource is None: + self._log_info(f"Resource '{resource_name}' not found in server '{server_name}'") + continue # Try next server + + return server_name, target_resource + + raise ValueError(f"Resource '{resource_name}' not found in any configured server") async def call_tool_streaming(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]: """REQUIRED From a27941e07a5404984599338d177b856adcf3deb0 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:08:18 +0200 Subject: [PATCH 08/21] Update plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../http/src/utcp_http/http_communication_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py index 9be24fc..15e2c23 100644 --- a/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) -if not logger.handlers: # Only add default handler if user didn't configure logging +if not logger.hasHandlers(): # Only add default handler if user didn't configure logging handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) logger.addHandler(handler) From 68b06be663408b05651be78e76dbf86a3e2e18c1 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:11:18 +0200 Subject: [PATCH 09/21] Update plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../http/src/utcp_http/sse_communication_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py index e4272a0..a5aac67 100644 --- a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -if not logger.handlers: # Only add default handler if user didn't configure logging +if not logger.hasHandlers(): # Only add default handler if user didn't configure logging handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) logger.addHandler(handler) From 33785d0559fefced673f85267bae86288886019e Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:12:27 +0200 Subject: [PATCH 10/21] Update plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../gql/src/utcp_gql/gql_communication_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py index 771aad4..d558e82 100644 --- a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py +++ b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -if not logger.handlers: # Only add default handler if user didn't configure logging +if not logger.hasHandlers(): # Only add default handler if user didn't configure logging handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) logger.addHandler(handler) From 677ec11186a35f87d4523f15d9e87492d154ef41 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:12:57 +0200 Subject: [PATCH 11/21] Update plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../mcp/src/utcp_mcp/mcp_communication_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py index 4619b2d..b8fc20f 100644 --- a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py +++ b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) -if not logger.handlers: # Only add default handler if user didn't configure logging +if not logger.hasHandlers(): # Only add default handler if user didn't configure logging handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) logger.addHandler(handler) From 0887a006c078c49bc52544e1a5c22a305580bbb4 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:13:33 +0200 Subject: [PATCH 12/21] Update plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../cli/src/utcp_cli/cli_communication_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py index cc5d172..abb1690 100644 --- a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py +++ b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py @@ -36,7 +36,7 @@ logger = logging.getLogger(__name__) -if not logger.handlers: # Only add default handler if user didn't configure logging +if not logger.hasHandlers(): # Only add default handler if user didn't configure logging handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) logger.addHandler(handler) From 26ba8c30db23670841748b0c39a61ce643bd9b75 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:14:29 +0200 Subject: [PATCH 13/21] Update plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../text/src/utcp_text/text_communication_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py b/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py index aaa156b..e35373b 100644 --- a/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py +++ b/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) -if not logger.handlers: # Only add default handler if user didn't configure logging +if not logger.hasHandlers(): # Only add default handler if user didn't configure logging handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) logger.addHandler(handler) From c685a6e63f4cce760236aa987ae740745e760734 Mon Sep 17 00:00:00 2001 From: Andrei Ghiurtu Date: Thu, 4 Sep 2025 09:42:51 +0300 Subject: [PATCH 14/21] Revert mcp specific config --- .../implementations/utcp_client_implementation.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/core/src/utcp/implementations/utcp_client_implementation.py b/core/src/utcp/implementations/utcp_client_implementation.py index 79ec753..01c13a9 100644 --- a/core/src/utcp/implementations/utcp_client_implementation.py +++ b/core/src/utcp/implementations/utcp_client_implementation.py @@ -112,22 +112,9 @@ async def register_manual(self, manual_call_template: CallTemplate) -> RegisterM result = await CommunicationProtocol.communication_protocols[manual_call_template.call_template_type].register_manual(self, manual_call_template) if result.success: - final_tools = [] for tool in result.manual.tools: if not tool.name.startswith(manual_call_template.name + "."): tool.name = manual_call_template.name + "." + tool.name - - if tool.tool_call_template.call_template_type != "mcp": - final_tools.append(tool) - else: - mcp_result = await CommunicationProtocol.communication_protocols["mcp"].register_manual(self, tool.tool_call_template) - if mcp_result.success: - for mcp_tool in mcp_result.manual.tools: - if not mcp_tool.name.startswith(tool.name + "."): - mcp_tool.name = tool.name + "." + mcp_tool.name - final_tools.extend(mcp_result.manual.tools) - - result.manual.tools = final_tools await self.config.tool_repository.save_manual(result.manual_call_template, result.manual) return result From 7c8f0d2536331c7523ba7226ebbfb83802079e02 Mon Sep 17 00:00:00 2001 From: Andrei Ghiurtu Date: Thu, 4 Sep 2025 09:57:52 +0300 Subject: [PATCH 15/21] Add server name to mcp tool --- .../mcp/src/utcp_mcp/mcp_communication_protocol.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py index b8fc20f..de2e8c4 100644 --- a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py +++ b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py @@ -83,6 +83,14 @@ async def _cleanup_all_sessions(self): await self._mcp_client.close_all_sessions() self._log_info("Cleaned up all sessions") + def _add_server_to_tool_name(self, tools, server_name: str): + """Prefix tool names with server name to ensure uniqueness.""" + for tool in tools: + if not tool.name.startswith(f"{server_name}."): + tool.name = f"{server_name}.{tool.name}" + + return tools + async def _list_tools_with_session(self, server_name: str, manual_call_template: 'McpCallTemplate'): """List tools using cached session when possible.""" try: @@ -180,6 +188,8 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call try: self._log_info(f"Discovering tools for server '{server_name}' via {server_config}") mcp_tools = await self._list_tools_with_session(server_name, manual_call_template) + mcp_tools = self._add_server_to_tool_name(mcp_tools, server_name) + self._log_info(f"Discovered {len(mcp_tools)} tools for server '{server_name}'") for mcp_tool in mcp_tools: # Convert mcp.Tool to utcp.data.tool.Tool @@ -202,7 +212,7 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call # Convert mcp.Resource to utcp.data.tool.Tool # Create a tool that reads the resource when called resource_tool = Tool( - name=f"resource_{mcp_resource.name}", + name=f"{server_name}.resource_{mcp_resource.name}", description=f"Read resource: {mcp_resource.description or mcp_resource.name}. URI: {mcp_resource.uri}", input_schema={ "type": "object", @@ -228,6 +238,7 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call except Exception as e: self._log_error(f"Failed to discover tools for server '{server_name}': {e}") errors.append(f"Failed to discover tools for server '{server_name}': {e}") + return RegisterManualResult( manual_call_template=manual_call_template, manual=UtcpManual( From c23b594c8dd6fb86fe92596faf5322efa0c81df9 Mon Sep 17 00:00:00 2001 From: Andrei Ghiurtu Date: Thu, 4 Sep 2025 18:49:23 +0300 Subject: [PATCH 16/21] Fix mcp tests --- .../mcp/tests/test_mcp_http_transport.py | 18 ++++----- .../mcp/tests/test_mcp_transport.py | 40 +++++++++---------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/plugins/communication_protocols/mcp/tests/test_mcp_http_transport.py b/plugins/communication_protocols/mcp/tests/test_mcp_http_transport.py index f8c626c..adaadb4 100644 --- a/plugins/communication_protocols/mcp/tests/test_mcp_http_transport.py +++ b/plugins/communication_protocols/mcp/tests/test_mcp_http_transport.py @@ -98,15 +98,15 @@ async def test_http_register_manual_discovers_tools( assert len(register_result.manual.tools) == 4 # Find the echo tool - echo_tool = next((tool for tool in register_result.manual.tools if tool.name == "echo"), None) + echo_tool = next((tool for tool in register_result.manual.tools if tool.name == f"{HTTP_SERVER_NAME}.echo"), None) assert echo_tool is not None assert "echoes back its input" in echo_tool.description # Check for other tools tool_names = [tool.name for tool in register_result.manual.tools] - assert "greet" in tool_names - assert "list_items" in tool_names - assert "add_numbers" in tool_names + assert f"{HTTP_SERVER_NAME}.greet" in tool_names + assert f"{HTTP_SERVER_NAME}.list_items" in tool_names + assert f"{HTTP_SERVER_NAME}.add_numbers" in tool_names @pytest.mark.asyncio @@ -120,7 +120,7 @@ async def test_http_structured_output( await transport.register_manual(None, http_mcp_provider) # Call the echo tool and verify the result - result = await transport.call_tool(None, "echo", {"message": "http_test"}, http_mcp_provider) + result = await transport.call_tool(None, f"{HTTP_SERVER_NAME}.echo", {"message": "http_test"}, http_mcp_provider) assert result == {"reply": "you said: http_test"} @@ -135,7 +135,7 @@ async def test_http_unstructured_output( await transport.register_manual(None, http_mcp_provider) # Call the greet tool and verify the result - result = await transport.call_tool(None, "greet", {"name": "Alice"}, http_mcp_provider) + result = await transport.call_tool(None, f"{HTTP_SERVER_NAME}.greet", {"name": "Alice"}, http_mcp_provider) assert result == "Hello, Alice!" @@ -150,7 +150,7 @@ async def test_http_list_output( await transport.register_manual(None, http_mcp_provider) # Call the list_items tool and verify the result - result = await transport.call_tool(None, "list_items", {"count": 3}, http_mcp_provider) + result = await transport.call_tool(None, f"{HTTP_SERVER_NAME}.list_items", {"count": 3}, http_mcp_provider) assert isinstance(result, list) assert len(result) == 3 @@ -170,7 +170,7 @@ async def test_http_numeric_output( await transport.register_manual(None, http_mcp_provider) # Call the add_numbers tool and verify the result - result = await transport.call_tool(None, "add_numbers", {"a": 5, "b": 7}, http_mcp_provider) + result = await transport.call_tool(None, f"{HTTP_SERVER_NAME}.add_numbers", {"a": 5, "b": 7}, http_mcp_provider) assert result == 12 @@ -191,5 +191,5 @@ async def test_http_deregister_manual( await transport.deregister_manual(None, http_mcp_provider) # Should still be able to call tools since we create fresh sessions - result = await transport.call_tool(None, "echo", {"message": "test"}, http_mcp_provider) + result = await transport.call_tool(None, f"{HTTP_SERVER_NAME}.echo", {"message": "test"}, http_mcp_provider) assert result == {"reply": "you said: test"} diff --git a/plugins/communication_protocols/mcp/tests/test_mcp_transport.py b/plugins/communication_protocols/mcp/tests/test_mcp_transport.py index 4af2691..cbd6073 100644 --- a/plugins/communication_protocols/mcp/tests/test_mcp_transport.py +++ b/plugins/communication_protocols/mcp/tests/test_mcp_transport.py @@ -55,15 +55,15 @@ async def test_register_manual_discovers_tools(transport: McpCommunicationProtoc assert len(register_result.manual.tools) == 4 # Find the echo tool - echo_tool = next((tool for tool in register_result.manual.tools if tool.name == "echo"), None) + echo_tool = next((tool for tool in register_result.manual.tools if tool.name ==f"{SERVER_NAME}.echo"), None) assert echo_tool is not None assert "echoes back its input" in echo_tool.description # Check for other tools tool_names = [tool.name for tool in register_result.manual.tools] - assert "greet" in tool_names - assert "list_items" in tool_names - assert "add_numbers" in tool_names + assert f"{SERVER_NAME}.greet" in tool_names + assert f"{SERVER_NAME}.list_items" in tool_names + assert f"{SERVER_NAME}.add_numbers" in tool_names @pytest.mark.asyncio @@ -71,7 +71,7 @@ async def test_call_tool_succeeds(transport: McpCommunicationProtocol, mcp_manua """Verify a successful tool call after registration.""" await transport.register_manual(None, mcp_manual) - result = await transport.call_tool(None, "echo", {"message": "test"}, mcp_manual) + result = await transport.call_tool(None, f"{SERVER_NAME}.echo", {"message": "test"}, mcp_manual) assert result == {"reply": "you said: test"} @@ -79,7 +79,7 @@ async def test_call_tool_succeeds(transport: McpCommunicationProtocol, mcp_manua @pytest.mark.asyncio async def test_call_tool_works_without_register(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate): """Verify that calling a tool works without prior registration in session-per-operation mode.""" - result = await transport.call_tool(None, "echo", {"message": "test"}, mcp_manual) + result = await transport.call_tool(None, f"{SERVER_NAME}.echo", {"message": "test"}, mcp_manual) assert result == {"reply": "you said: test"} @@ -88,7 +88,7 @@ async def test_structured_output_tool(transport: McpCommunicationProtocol, mcp_m """Test that tools with structured output (TypedDict) work correctly.""" await transport.register_manual(None, mcp_manual) - result = await transport.call_tool(None, "echo", {"message": "test"}, mcp_manual) + result = await transport.call_tool(None, f"{SERVER_NAME}.echo", {"message": "test"}, mcp_manual) assert result == {"reply": "you said: test"} @@ -97,7 +97,7 @@ async def test_unstructured_string_output(transport: McpCommunicationProtocol, m """Test that tools returning plain strings work correctly.""" await transport.register_manual(None, mcp_manual) - result = await transport.call_tool(None, "greet", {"name": "Alice"}, mcp_manual) + result = await transport.call_tool(None, f"{SERVER_NAME}.greet", {"name": "Alice"}, mcp_manual) assert result == "Hello, Alice!" @@ -106,7 +106,7 @@ async def test_list_output(transport: McpCommunicationProtocol, mcp_manual: McpC """Test that tools returning lists work correctly.""" await transport.register_manual(None, mcp_manual) - result = await transport.call_tool(None, "list_items", {"count": 3}, mcp_manual) + result = await transport.call_tool(None, f"{SERVER_NAME}.list_items", {"count": 3}, mcp_manual) assert isinstance(result, list) assert len(result) == 3 @@ -118,7 +118,7 @@ async def test_numeric_output(transport: McpCommunicationProtocol, mcp_manual: M """Test that tools returning numeric values work correctly.""" await transport.register_manual(None, mcp_manual) - result = await transport.call_tool(None, "add_numbers", {"a": 5, "b": 7}, mcp_manual) + result = await transport.call_tool(None, f"{SERVER_NAME}.add_numbers", {"a": 5, "b": 7}, mcp_manual) assert result == 12 @@ -132,7 +132,7 @@ async def test_deregister_manual(transport: McpCommunicationProtocol, mcp_manual await transport.deregister_manual(None, mcp_manual) - result = await transport.call_tool(None, "echo", {"message": "test"}, mcp_manual) + result = await transport.call_tool(None, f"{SERVER_NAME}.echo", {"message": "test"}, mcp_manual) assert result == {"reply": "you said: test"} @@ -145,7 +145,7 @@ async def test_register_resources_as_tools_disabled(transport: McpCommunicationP # Check that no resource tools are present tool_names = [tool.name for tool in register_result.manual.tools] - resource_tools = [name for name in tool_names if name.startswith("resource_")] + resource_tools = [name for name in tool_names if name.startswith(f"{SERVER_NAME}.resource_")] assert len(resource_tools) == 0 @@ -160,13 +160,13 @@ async def test_register_resources_as_tools_enabled(transport: McpCommunicationPr # Check that resource tools are present tool_names = [tool.name for tool in register_result.manual.tools] - resource_tools = [name for name in tool_names if name.startswith("resource_")] + resource_tools = [name for name in tool_names if name.startswith(f"{SERVER_NAME}.resource_")] assert len(resource_tools) == 2 - assert "resource_get_test_document" in resource_tools - assert "resource_get_config" in resource_tools + assert f"{SERVER_NAME}.resource_get_test_document" in resource_tools + assert f"{SERVER_NAME}.resource_get_config" in resource_tools # Check resource tool properties - test_doc_tool = next((tool for tool in register_result.manual.tools if tool.name == "resource_get_test_document"), None) + test_doc_tool = next((tool for tool in register_result.manual.tools if tool.name == f"{SERVER_NAME}.resource_get_test_document"), None) assert test_doc_tool is not None assert "Read resource:" in test_doc_tool.description assert "file://test_document.txt" in test_doc_tool.description @@ -179,7 +179,7 @@ async def test_call_resource_tool(transport: McpCommunicationProtocol, mcp_manua await transport.register_manual(None, mcp_manual_with_resources) # Call the test document resource - result = await transport.call_tool(None, "resource_get_test_document", {}, mcp_manual_with_resources) + result = await transport.call_tool(None, f"{SERVER_NAME}.resource_get_test_document", {}, mcp_manual_with_resources) # Check that we get the resource content assert isinstance(result, dict) @@ -207,7 +207,7 @@ async def test_call_resource_tool_json_content(transport: McpCommunicationProtoc await transport.register_manual(None, mcp_manual_with_resources) # Call the config.json resource - result = await transport.call_tool(None, "resource_get_config", {}, mcp_manual_with_resources) + result = await transport.call_tool(None, f"{SERVER_NAME}.resource_get_config", {}, mcp_manual_with_resources) # Check that we get the resource content assert isinstance(result, dict) @@ -232,14 +232,14 @@ async def test_call_resource_tool_json_content(transport: McpCommunicationProtoc async def test_call_nonexistent_resource_tool(transport: McpCommunicationProtocol, mcp_manual_with_resources: McpCallTemplate): """Verify that calling a non-existent resource tool raises an error.""" with pytest.raises(ValueError, match="Resource 'nonexistent' not found in any configured server"): - await transport.call_tool(None, "resource_nonexistent", {}, mcp_manual_with_resources) + await transport.call_tool(None, f"{SERVER_NAME}.resource_nonexistent", {}, mcp_manual_with_resources) @pytest.mark.asyncio async def test_resource_tool_without_registration(transport: McpCommunicationProtocol, mcp_manual_with_resources: McpCallTemplate): """Verify that resource tools work even without prior registration.""" # Don't register the manual first - test direct call - result = await transport.call_tool(None, "resource_get_test_document", {}, mcp_manual_with_resources) + result = await transport.call_tool(None, f"{SERVER_NAME}.resource_get_test_document", {}, mcp_manual_with_resources) # Should still work and return content assert isinstance(result, dict) From 1320bf52048e0a321a682cc829e56504f1a9e38c Mon Sep 17 00:00:00 2001 From: Andrei Ghiurtu Date: Thu, 4 Sep 2025 18:50:50 +0300 Subject: [PATCH 17/21] Replace handlers with hasHandlers() --- core/src/utcp/__init__.py | 2 +- .../src/utcp_http/streamable_http_communication_protocol.py | 2 +- .../socket/src/utcp_socket/tcp_communication_protocol.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/utcp/__init__.py b/core/src/utcp/__init__.py index d6f3c35..cfe0dd4 100644 --- a/core/src/utcp/__init__.py +++ b/core/src/utcp/__init__.py @@ -3,7 +3,7 @@ logger = logging.getLogger("utcp") -if not logger.handlers: # Only add default handler if user didn't configure logging +if not logger.hasHandlers(): # Only add default handler if user didn't configure logging handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) logger.addHandler(handler) diff --git a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py index df8e86c..2ba868e 100644 --- a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) -if not logger.handlers: # Only add default handler if user didn't configure logging +if not logger.hasHandlers(): # Only add default handler if user didn't configure logging handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) logger.addHandler(handler) diff --git a/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py b/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py index 9db2c13..cab2665 100644 --- a/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py +++ b/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -if not logger.handlers: # Only add default handler if user didn't configure logging +if not logger.hasHandlers(): # Only add default handler if user didn't configure logging handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) logger.addHandler(handler) From 8a0a4492e20e01d657da3aa36ddc6eb9479c114a Mon Sep 17 00:00:00 2001 From: perrozzi Date: Sun, 7 Sep 2025 10:48:50 +0200 Subject: [PATCH 18/21] Enhance documentation: Move docs to class docstrings and improve README files (#59) * Move documentation from docs folder to class docstrings and document undocumented classes * Improve README files with comprehensive documentation and cross-references * Update core/src/utcp/implementations/tag_search.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update plugins/communication_protocols/cli/src/utcp_cli/cli_call_template.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update some docstrings --------- Co-authored-by: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- README.md | 91 ++-- core/README.md | 494 ------------------ .../utcp_serializer_validation_error.py | 11 +- .../filter_dict_post_processor.py | 18 + .../limit_strings_post_processor.py | 18 + core/src/utcp/implementations/tag_search.py | 19 +- core/src/utcp/plugins/plugin_loader.py | 10 + plugins/communication_protocols/cli/README.md | 163 +++++- .../cli/src/utcp_cli/cli_call_template.py | 82 ++- .../utcp_cli/cli_communication_protocol.py | 141 ++--- .../communication_protocols/http/README.md | 145 ++++- .../http/src/utcp_http/http_call_template.py | 64 +++ .../http/src/utcp_http/openapi_converter.py | 74 ++- plugins/communication_protocols/mcp/README.md | 254 ++++++++- .../mcp/src/utcp_mcp/mcp_call_template.py | 81 +++ .../communication_protocols/text/README.md | 127 ++++- 16 files changed, 1163 insertions(+), 629 deletions(-) delete mode 100644 core/README.md diff --git a/README.md b/README.md index 5c02e85..1adedc7 100644 --- a/README.md +++ b/README.md @@ -19,47 +19,76 @@ In contrast to other protocols, UTCP places a strong emphasis on: ![MCP vs. UTCP](https://github.com/user-attachments/assets/3cadfc19-8eea-4467-b606-66e580b89444) -## New Architecture in 1.0.0 +## Repository Structure -UTCP has been refactored into a core library and a set of optional plugins. +This repository contains the complete UTCP Python implementation: + +- **[`core/`](core/)** - Core `utcp` package with foundational components ([README](core/README.md)) +- **[`plugins/communication_protocols/`](plugins/communication_protocols/)** - Protocol-specific plugins: + - [`http/`](plugins/communication_protocols/http/) - HTTP/REST, SSE, streaming, OpenAPI ([README](plugins/communication_protocols/http/README.md)) + - [`cli/`](plugins/communication_protocols/cli/) - Command-line tools ([README](plugins/communication_protocols/cli/README.md)) + - [`mcp/`](plugins/communication_protocols/mcp/) - Model Context Protocol ([README](plugins/communication_protocols/mcp/README.md)) + - [`text/`](plugins/communication_protocols/text/) - File-based tools ([README](plugins/communication_protocols/text/README.md)) + - [`socket/`](plugins/communication_protocols/socket/) - TCP/UDP (🚧 In Progress) + - [`gql/`](plugins/communication_protocols/gql/) - GraphQL (🚧 In Progress) + +## Architecture Overview + +UTCP uses a modular architecture with a core library and protocol plugins: ### Core Package (`utcp`) -The `utcp` package provides the central components and interfaces: -* **Data Models**: Pydantic models for `Tool`, `CallTemplate`, `UtcpManual`, and `Auth`. -* **Pluggable Interfaces**: - * `CommunicationProtocol`: Defines the contract for protocol-specific communication (e.g., HTTP, CLI). - * `ConcurrentToolRepository`: An interface for storing and retrieving tools with thread-safe access. - * `ToolSearchStrategy`: An interface for implementing tool search algorithms. - * `VariableSubstitutor`: Handles variable substitution in configurations. - * `ToolPostProcessor`: Allows for modifying tool results before they are returned. -* **Default Implementations**: - * `UtcpClient`: The main client for interacting with the UTCP ecosystem. - * `InMemToolRepository`: An in-memory tool repository with asynchronous read-write locks. - * `TagAndDescriptionWordMatchStrategy`: An improved search strategy that matches on tags and description keywords. - -### Protocol Plugins - -Communication protocols are now separate, installable packages. This keeps the core lean and allows users to install only the protocols they need. -* `utcp-http`: Supports HTTP, SSE, and streamable HTTP, plus an OpenAPI converter. -* `utcp-cli`: For wrapping local command-line tools. -* `utcp-mcp`: For interoperability with the Model Context Protocol (MCP). -* `utcp-text`: For reading text files. -* `utcp-socket`: Scaffolding for TCP and UDP protocols. (Work in progress, requires update) -* `utcp-gql`: Scaffolding for GraphQL. (Work in progress, requires update) - -## Installation - -Install the core library and any required protocol plugins. +The [`core/`](core/) directory contains the foundational components: +- **Data Models**: Pydantic models for `Tool`, `CallTemplate`, `UtcpManual`, and `Auth` +- **Client Interface**: Main `UtcpClient` for tool interaction +- **Plugin System**: Extensible interfaces for protocols, repositories, and search +- **Default Implementations**: Built-in tool storage and search strategies + +## Quick Start + +### Installation + +Install the core library and any required protocol plugins: ```bash -# Install the core client and the HTTP plugin +# Install core + HTTP plugin (most common) pip install utcp utcp-http -# Install the CLI plugin as well -pip install utcp-cli +# Install additional plugins as needed +pip install utcp-cli utcp-mcp utcp-text ``` +### Basic Usage + +```python +from utcp.utcp_client import UtcpClient + +# Create client with HTTP API +client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "my_api", + "call_template_type": "http", + "url": "https://api.example.com/utcp" + }] +}) + +# Call a tool +result = await client.call_tool("my_api.get_data", {"id": "123"}) +``` + +## Protocol Plugins + +UTCP supports multiple communication protocols through dedicated plugins: + +| Plugin | Description | Status | Documentation | +|--------|-------------|--------|---------------| +| [`utcp-http`](plugins/communication_protocols/http/) | HTTP/REST APIs, SSE, streaming | ✅ Stable | [HTTP Plugin README](plugins/communication_protocols/http/README.md) | +| [`utcp-cli`](plugins/communication_protocols/cli/) | Command-line tools | ✅ Stable | [CLI Plugin README](plugins/communication_protocols/cli/README.md) | +| [`utcp-mcp`](plugins/communication_protocols/mcp/) | Model Context Protocol | ✅ Stable | [MCP Plugin README](plugins/communication_protocols/mcp/README.md) | +| [`utcp-text`](plugins/communication_protocols/text/) | Local file-based tools | ✅ Stable | [Text Plugin README](plugins/communication_protocols/text/README.md) | +| [`utcp-socket`](plugins/communication_protocols/socket/) | TCP/UDP protocols | 🚧 In Progress | [Socket Plugin README](plugins/communication_protocols/socket/README.md) | +| [`utcp-gql`](plugins/communication_protocols/gql/) | GraphQL APIs | 🚧 In Progress | [GraphQL Plugin README](plugins/communication_protocols/gql/README.md) | + For development, you can install the packages in editable mode from the cloned repository: ```bash diff --git a/core/README.md b/core/README.md deleted file mode 100644 index 3eee92e..0000000 --- a/core/README.md +++ /dev/null @@ -1,494 +0,0 @@ -# Universal Tool Calling Protocol (UTCP) 1.0.1 - -[![Follow Org](https://img.shields.io/github/followers/universal-tool-calling-protocol?label=Follow%20Org&logo=github)](https://github.com/universal-tool-calling-protocol) -[![PyPI Downloads](https://static.pepy.tech/badge/utcp)](https://pepy.tech/projects/utcp) -[![License](https://img.shields.io/github/license/universal-tool-calling-protocol/python-utcp)](https://github.com/universal-tool-calling-protocol/python-utcp/blob/main/LICENSE) -[![CDTM S23](https://img.shields.io/badge/CDTM-S23-0b84f3)](https://cdtm.com/) - -## Introduction - -The Universal Tool Calling Protocol (UTCP) is a modern, flexible, and scalable standard for defining and interacting with tools across a wide variety of communication protocols. UTCP 1.0.0 introduces a modular core with a plugin-based architecture, making it more extensible, testable, and easier to package. - -In contrast to other protocols, UTCP places a strong emphasis on: - -* **Scalability**: UTCP is designed to handle a large number of tools and providers without compromising performance. -* **Extensibility**: A pluggable architecture allows developers to easily add new communication protocols, tool storage mechanisms, and search strategies without modifying the core library. -* **Interoperability**: With a growing ecosystem of protocol plugins (including HTTP, SSE, CLI, and more), UTCP can integrate with almost any existing service or infrastructure. -* **Ease of Use**: The protocol is built on simple, well-defined Pydantic models, making it easy for developers to implement and use. - - -![MCP vs. UTCP](https://github.com/user-attachments/assets/3cadfc19-8eea-4467-b606-66e580b89444) - -## New Architecture in 1.0.0 - -UTCP has been refactored into a core library and a set of optional plugins. - -### Core Package (`utcp`) - -The `utcp` package provides the central components and interfaces: -* **Data Models**: Pydantic models for `Tool`, `CallTemplate`, `UtcpManual`, and `Auth`. -* **Pluggable Interfaces**: - * `CommunicationProtocol`: Defines the contract for protocol-specific communication (e.g., HTTP, CLI). - * `ConcurrentToolRepository`: An interface for storing and retrieving tools with thread-safe access. - * `ToolSearchStrategy`: An interface for implementing tool search algorithms. - * `VariableSubstitutor`: Handles variable substitution in configurations. - * `ToolPostProcessor`: Allows for modifying tool results before they are returned. -* **Default Implementations**: - * `UtcpClient`: The main client for interacting with the UTCP ecosystem. - * `InMemToolRepository`: An in-memory tool repository with asynchronous read-write locks. - * `TagAndDescriptionWordMatchStrategy`: An improved search strategy that matches on tags and description keywords. - -### Protocol Plugins - -Communication protocols are now separate, installable packages. This keeps the core lean and allows users to install only the protocols they need. -* `utcp-http`: Supports HTTP, SSE, and streamable HTTP, plus an OpenAPI converter. -* `utcp-cli`: For wrapping local command-line tools. -* `utcp-mcp`: For interoperability with the Model Context Protocol (MCP). -* `utcp-text`: For reading text files. -* `utcp-socket`: Scaffolding for TCP and UDP protocols. (Work in progress, requires update) -* `utcp-gql`: Scaffolding for GraphQL. (Work in progress, requires update) - -## Installation - -Install the core library and any required protocol plugins. - -```bash -# Install the core client and the HTTP plugin -pip install utcp utcp-http - -# Install the CLI plugin as well -pip install utcp-cli -``` - -For development, you can install the packages in editable mode from the cloned repository: - -```bash -# Clone the repository -git clone https://github.com/universal-tool-calling-protocol/python-utcp.git -cd python-utcp - -# Install the core package in editable mode with dev dependencies -pip install -e core[dev] - -# Install a specific protocol plugin in editable mode -pip install -e plugins/communication_protocols/http -``` - -## Migration Guide from 0.x to 1.0.0 - -Version 1.0.0 introduces several breaking changes. Follow these steps to migrate your project. - -1. **Update Dependencies**: Install the new `utcp` core package and the specific protocol plugins you use (e.g., `utcp-http`, `utcp-cli`). -2. **Configuration**: - * **Configuration Object**: `UtcpClient` is initialized with a `UtcpClientConfig` object, dict or a path to a JSON file containing the configuration. - * **Manual Call Templates**: The `providers_file_path` option is removed. Instead of a file path, you now provide a list of `manual_call_templates` directly within the `UtcpClientConfig`. - * **Terminology**: The term `provider` has been replaced with `call_template`, and `provider_type` is now `call_template_type`. - * **Streamable HTTP**: The `call_template_type` `http_stream` has been renamed to `streamable_http`. -3. **Update Imports**: Change your imports to reflect the new modular structure. For example, `from utcp.client.transport_interfaces.http_transport import HttpProvider` becomes `from utcp_http.http_call_template import HttpCallTemplate`. -4. **Tool Search**: If you were using the default search, the new strategy is `TagAndDescriptionWordMatchStrategy`. This is the new default and requires no changes unless you were implementing a custom strategy. -5. **Tool Naming**: Tool names are now namespaced as `manual_name.tool_name`. The client handles this automatically. -6 **Variable Substitution Namespacing**: Variables that are subsituted in different `call_templates`, are first namespaced with the name of the manual with the `_` duplicated. So a key in a tool call template called `API_KEY` from the manual `manual_1` would be converted to `manual__1_API_KEY`. - -## Usage Examples - -### 1. Using the UTCP Client - -**`config.json`** (Optional) - -You can define a comprehensive client configuration in a JSON file. All of these fields are optional. - -```json -{ - "variables": { - "openlibrary_URL": "https://openlibrary.org/static/openapi.json" - }, - "load_variables_from": [ - { - "variable_loader_type": "dotenv", - "env_file_path": ".env" - } - ], - "tool_repository": { - "tool_repository_type": "in_memory" - }, - "tool_search_strategy": { - "tool_search_strategy_type": "tag_and_description_word_match" - }, - "manual_call_templates": [ - { - "name": "openlibrary", - "call_template_type": "http", - "http_method": "GET", - "url": "${URL}", - "content_type": "application/json" - }, - ], - "post_processing": [ - { - "tool_post_processor_type": "filter_dict", - "only_include_keys": ["name", "key"], - "only_include_tools": ["openlibrary.read_search_authors_json_search_authors_json_get"] - } - ] -} -``` - -**`client.py`** - -```python -import asyncio -from utcp.utcp_client import UtcpClient -from utcp.data.utcp_client_config import UtcpClientConfig - -async def main(): - # The UtcpClient can be created with a config file path, a dict, or a UtcpClientConfig object. - - # Option 1: Initialize from a config file path - # client_from_file = await UtcpClient.create(config="./config.json") - - # Option 2: Initialize from a dictionary - client_from_dict = await UtcpClient.create(config={ - "variables": { - "openlibrary_URL": "https://openlibrary.org/static/openapi.json" - }, - "load_variables_from": [ - { - "variable_loader_type": "dotenv", - "env_file_path": ".env" - } - ], - "tool_repository": { - "tool_repository_type": "in_memory" - }, - "tool_search_strategy": { - "tool_search_strategy_type": "tag_and_description_word_match" - }, - "manual_call_templates": [ - { - "name": "openlibrary", - "call_template_type": "http", - "http_method": "GET", - "url": "${URL}", - "content_type": "application/json" - } - ], - "post_processing": [ - { - "tool_post_processor_type": "filter_dict", - "only_include_keys": ["name", "key"], - "only_include_tools": ["openlibrary.read_search_authors_json_search_authors_json_get"] - } - ] - }) - - # Option 3: Initialize with a full-featured UtcpClientConfig object - from utcp_http.http_call_template import HttpCallTemplate - from utcp.data.variable_loader import VariableLoaderSerializer - from utcp.interfaces.tool_post_processor import ToolPostProcessorConfigSerializer - - config_obj = UtcpClientConfig( - variables={"openlibrary_URL": "https://openlibrary.org/static/openapi.json"}, - load_variables_from=[ - VariableLoaderSerializer().validate_dict({ - "variable_loader_type": "dotenv", "env_file_path": ".env" - }) - ], - manual_call_templates=[ - HttpCallTemplate( - name="openlibrary", - call_template_type="http", - http_method="GET", - url="${URL}", - content_type="application/json" - ) - ], - post_processing=[ - ToolPostProcessorConfigSerializer().validate_dict({ - "tool_post_processor_type": "filter_dict", - "only_include_keys": ["name", "key"], - "only_include_tools": ["openlibrary.read_search_authors_json_search_authors_json_get"] - }) - ] - ) - client = await UtcpClient.create(config=config_obj) - - # Call a tool. The name is namespaced: `manual_name.tool_name` - result = await client.call_tool( - tool_name="openlibrary.read_search_authors_json_search_authors_json_get", - tool_args={"q": "J. K. Rowling"} - ) - - print(result) - -if __name__ == "__main__": - asyncio.run(main()) -``` - -### 2. Providing a UTCP Manual - -A `UTCPManual` describes the tools you offer. The key change is replacing `tool_provider` with `call_template`. - -**`server.py`** - -UTCP decorator version: - -```python -from fastapi import FastAPI -from utcp_http.http_call_template import HttpCallTemplate -from utcp.data.utcp_manual import UtcpManual -from utcp.python_specific_tooling.tool_decorator import utcp_tool - -app = FastAPI() - -# The discovery endpoint returns the tool manual -@app.get("/utcp") -def utcp_discovery(): - return UtcpManual.create_from_decorators(manual_version="1.0.0") - -# The actual tool endpoint -@utcp_tool(tool_call_template=HttpCallTemplate( - name="get_weather", - url=f"https://example.com/api/weather", - http_method="GET" -), tags=["weather"]) -@app.get("/api/weather") -def get_weather(location: str): - return {"temperature": 22.5, "conditions": "Sunny"} -``` - - -No UTCP dependencies server version: - -```python -from fastapi import FastAPI - -app = FastAPI() - -# The discovery endpoint returns the tool manual -@app.get("/utcp") -def utcp_discovery(): - return { - "manual_version": "1.0.0", - "utcp_version": "1.0.1", - "tools": [ - { - "name": "get_weather", - "description": "Get current weather for a location", - "tags": ["weather"], - "inputs": { - "type": "object", - "properties": { - "location": {"type": "string"} - } - }, - "outputs": { - "type": "object", - "properties": { - "temperature": {"type": "number"}, - "conditions": {"type": "string"} - } - }, - "call_template": { - "call_template_type": "http", - "url": "https://example.com/api/weather", - "http_method": "GET" - } - } - ] - } - -# The actual tool endpoint -@app.get("/api/weather") -def get_weather(location: str): - return {"temperature": 22.5, "conditions": "Sunny"} -``` - -### 3. Full examples - -You can find full examples in the [examples repository](https://github.com/universal-tool-calling-protocol/utcp-examples). - -## Protocol Specification - -### `UtcpManual` and `Tool` Models - -The `tool_provider` object inside a `Tool` has been replaced by `call_template`. - -```json -{ - "manual_version": "string", - "utcp_version": "string", - "tools": [ - { - "name": "string", - "description": "string", - "inputs": { ... }, - "outputs": { ... }, - "tags": ["string"], - "call_template": { - "call_template_type": "http", - "url": "https://...", - "http_method": "GET" - } - } - ] -} -``` - -## Call Template Configuration Examples - -Configuration examples for each protocol. Remember to replace `provider_type` with `call_template_type`. - -### HTTP Call Template - -```json -{ - "name": "my_rest_api", - "call_template_type": "http", // Required - "url": "https://api.example.com/users/{user_id}", // Required - "http_method": "POST", // Required, default: "GET" - "content_type": "application/json", // Optional, default: "application/json" - "auth": { // Optional, example using ApiKeyAuth for a Bearer token. The client must prepend "Bearer " to the token. - "auth_type": "api_key", - "api_key": "Bearer $API_KEY", // Required - "var_name": "Authorization", // Optional, default: "X-Api-Key" - "location": "header" // Optional, default: "header" - }, - "headers": { // Optional - "X-Custom-Header": "value" - }, - "body_field": "body", // Optional, default: "body" - "header_fields": ["user_id"] // Optional -} -``` - -### SSE (Server-Sent Events) Call Template - -```json -{ - "name": "my_sse_stream", - "call_template_type": "sse", // Required - "url": "https://api.example.com/events", // Required - "event_type": "message", // Optional - "reconnect": true, // Optional, default: true - "retry_timeout": 30000, // Optional, default: 30000 (ms) - "auth": { // Optional, example using BasicAuth - "auth_type": "basic", - "username": "${USERNAME}", // Required - "password": "${PASSWORD}" // Required - }, - "headers": { // Optional - "X-Client-ID": "12345" - }, - "body_field": null, // Optional - "header_fields": [] // Optional -} -``` - -### Streamable HTTP Call Template - -Note the name change from `http_stream` to `streamable_http`. - -```json -{ - "name": "streaming_data_source", - "call_template_type": "streamable_http", // Required - "url": "https://api.example.com/stream", // Required - "http_method": "POST", // Optional, default: "GET" - "content_type": "application/octet-stream", // Optional, default: "application/octet-stream" - "chunk_size": 4096, // Optional, default: 4096 - "timeout": 60000, // Optional, default: 60000 (ms) - "auth": null, // Optional - "headers": {}, // Optional - "body_field": "data", // Optional - "header_fields": [] // Optional -} -``` - -### CLI Call Template - -```json -{ - "name": "my_cli_tool", - "call_template_type": "cli", // Required - "command_name": "my-command --utcp", // Required - "env_vars": { // Optional - "MY_VAR": "my_value" - }, - "working_dir": "/path/to/working/directory", // Optional - "auth": null // Optional (always null for CLI) -} -``` - -### Text Call Template - -```json -{ - "name": "my_text_manual", - "call_template_type": "text", // Required - "file_path": "./manuals/my_manual.json", // Required - "auth": null // Optional (always null for Text) -} -``` - -### MCP (Model Context Protocol) Call Template - -```json -{ - "name": "my_mcp_server", - "call_template_type": "mcp", // Required - "config": { // Required - "mcpServers": { - "server_name": { - "transport": "stdio", - "command": ["python", "-m", "my_mcp_server"] - } - } - }, - "auth": { // Optional, example using OAuth2 - "auth_type": "oauth2", - "token_url": "https://auth.example.com/token", // Required - "client_id": "${CLIENT_ID}", // Required - "client_secret": "${CLIENT_SECRET}", // Required - "scope": "read:tools" // Optional - } -} -``` - -## Testing - -The testing structure has been updated to reflect the new core/plugin split. - -### Running Tests - -To run all tests for the core library and all plugins: -```bash -# Ensure you have installed all dev dependencies -python -m pytest -``` - -To run tests for a specific package (e.g., the core library): -```bash -python -m pytest core/tests/ -``` - -To run tests for a specific plugin (e.g., HTTP): -```bash -python -m pytest plugins/communication_protocols/http/tests/ -v -``` - -To run tests with coverage: -```bash -python -m pytest --cov=utcp --cov-report=xml -``` - -## Build - -The build process now involves building each package (`core` and `plugins`) separately if needed, though they are published to PyPI independently. - -1. Create and activate a virtual environment. -2. Install build dependencies: `pip install build`. -3. Navigate to the package directory (e.g., `cd core`). -4. Run the build: `python -m build`. -5. The distributable files (`.whl` and `.tar.gz`) will be in the `dist/` directory. - -## [Contributors](https://www.utcp.io/about) diff --git a/core/src/utcp/exceptions/utcp_serializer_validation_error.py b/core/src/utcp/exceptions/utcp_serializer_validation_error.py index 1a935df..98bafde 100644 --- a/core/src/utcp/exceptions/utcp_serializer_validation_error.py +++ b/core/src/utcp/exceptions/utcp_serializer_validation_error.py @@ -1,3 +1,12 @@ class UtcpSerializerValidationError(Exception): """REQUIRED - Exception raised when a serializer validation fails.""" + Exception raised when a serializer validation fails. + + Thrown by serializers when they cannot validate or convert data structures + due to invalid format, missing required fields, or type mismatches. + Contains the original validation error details for debugging. + + Usage: + Typically caught when loading configuration files or processing + external data that doesn't conform to UTCP specifications. + """ diff --git a/core/src/utcp/implementations/post_processors/filter_dict_post_processor.py b/core/src/utcp/implementations/post_processors/filter_dict_post_processor.py index 10d9573..3e31104 100644 --- a/core/src/utcp/implementations/post_processors/filter_dict_post_processor.py +++ b/core/src/utcp/implementations/post_processors/filter_dict_post_processor.py @@ -10,6 +10,22 @@ from utcp.utcp_client import UtcpClient class FilterDictPostProcessor(ToolPostProcessor): + """REQUIRED + Post-processor that filters dictionary keys from tool results. + + Provides flexible filtering capabilities to include or exclude specific keys + from dictionary results, with support for nested dictionaries and lists. + Can be configured to apply filtering only to specific tools or manuals. + + Attributes: + tool_post_processor_type: Always "filter_dict" for this processor. + exclude_keys: List of keys to remove from dictionary results. + only_include_keys: List of keys to keep in dictionary results (all others removed). + exclude_tools: List of tool names to skip processing for. + only_include_tools: List of tool names to process (all others skipped). + exclude_manuals: List of manual names to skip processing for. + only_include_manuals: List of manual names to process (all others skipped). + """ tool_post_processor_type: Literal["filter_dict"] = "filter_dict" exclude_keys: Optional[List[str]] = None only_include_keys: Optional[List[str]] = None @@ -89,6 +105,8 @@ def _filter_dict_only_include_keys(self, result: Any) -> Any: return result class FilterDictPostProcessorConfigSerializer(Serializer[FilterDictPostProcessor]): + """REQUIRED + Serializer for FilterDictPostProcessor configuration.""" def to_dict(self, obj: FilterDictPostProcessor) -> dict: return obj.model_dump() diff --git a/core/src/utcp/implementations/post_processors/limit_strings_post_processor.py b/core/src/utcp/implementations/post_processors/limit_strings_post_processor.py index c0a19a1..da28eb8 100644 --- a/core/src/utcp/implementations/post_processors/limit_strings_post_processor.py +++ b/core/src/utcp/implementations/post_processors/limit_strings_post_processor.py @@ -10,6 +10,22 @@ from utcp.utcp_client import UtcpClient class LimitStringsPostProcessor(ToolPostProcessor): + """REQUIRED + Post-processor that limits the length of string values in tool results. + + Truncates string values to a specified maximum length to prevent + excessively large responses. Processes nested dictionaries and lists + recursively. Can be configured to apply limiting only to specific + tools or manuals. + + Attributes: + tool_post_processor_type: Always "limit_strings" for this processor. + limit: Maximum length for string values (default: 10000 characters). + exclude_tools: List of tool names to skip processing for. + only_include_tools: List of tool names to process (all others skipped). + exclude_manuals: List of manual names to skip processing for. + only_include_manuals: List of manual names to process (all others skipped). + """ tool_post_processor_type: Literal["limit_strings"] = "limit_strings" limit: int = 10000 exclude_tools: Optional[List[str]] = None @@ -39,6 +55,8 @@ def _process_object(self, obj: Any) -> Any: return obj class LimitStringsPostProcessorConfigSerializer(Serializer[LimitStringsPostProcessor]): + """REQUIRED + Serializer for LimitStringsPostProcessor configuration.""" def to_dict(self, obj: LimitStringsPostProcessor) -> dict: return obj.model_dump() diff --git a/core/src/utcp/implementations/tag_search.py b/core/src/utcp/implementations/tag_search.py index d258c63..f11bf40 100644 --- a/core/src/utcp/implementations/tag_search.py +++ b/core/src/utcp/implementations/tag_search.py @@ -9,7 +9,24 @@ class TagAndDescriptionWordMatchStrategy(ToolSearchStrategy): """REQUIRED Tag and description word match strategy. - This strategy matches tools based on the presence of tags and words in the description. + Implements a weighted scoring system that matches tools based on: + 1. Tag matches (higher weight) + 2. Description word matches (lower weight) + + The strategy normalizes queries to lowercase, extracts words using regex, + and calculates relevance scores for each tool. Results are sorted by + score in descending order. + + Attributes: + tool_search_strategy_type: Always "tag_and_description_word_match". + description_weight: Weight multiplier for description word matches (default: 1.0). + tag_weight: Weight multiplier for tag matches (default: 3.0). + + Scoring Algorithm: + - Each matching tag contributes tag_weight points + - Each matching description word contributes description_weight points + - Tools with higher scores are ranked first + - Tools with zero score are included in results (ranked last) """ tool_search_strategy_type: Literal["tag_and_description_word_match"] = "tag_and_description_word_match" description_weight: float = 1 diff --git a/core/src/utcp/plugins/plugin_loader.py b/core/src/utcp/plugins/plugin_loader.py index 18b6b0b..4666f1f 100644 --- a/core/src/utcp/plugins/plugin_loader.py +++ b/core/src/utcp/plugins/plugin_loader.py @@ -1,6 +1,16 @@ import importlib.metadata def _load_plugins(): + """REQUIRED + Load and register all built-in and external UTCP plugins. + + Registers core serializers for authentication, variable loading, tool repositories, + search strategies, and post-processors. Also discovers and loads external plugins + through the 'utcp.plugins' entry point group. + + This function is called automatically by ensure_plugins_initialized() and should + not be called directly. + """ from utcp.plugins.discovery import register_auth, register_variable_loader, register_tool_repository, register_tool_search_strategy, register_tool_post_processor from utcp.interfaces.concurrent_tool_repository import ConcurrentToolRepositoryConfigSerializer from utcp.interfaces.tool_search_strategy import ToolSearchStrategyConfigSerializer diff --git a/plugins/communication_protocols/cli/README.md b/plugins/communication_protocols/cli/README.md index 8febb5a..246493c 100644 --- a/plugins/communication_protocols/cli/README.md +++ b/plugins/communication_protocols/cli/README.md @@ -1 +1,162 @@ -Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file +# UTCP CLI Plugin + +[![PyPI Downloads](https://static.pepy.tech/badge/utcp-cli)](https://pepy.tech/projects/utcp-cli) + +Command-line interface plugin for UTCP, enabling integration with command-line tools and processes. + +## Features + +- **Command Execution**: Run any command-line tool as a UTCP tool +- **Environment Variables**: Secure credential and configuration passing +- **Working Directory Control**: Execute commands in specific directories +- **Input/Output Handling**: Support for stdin, stdout, stderr processing +- **Cross-Platform**: Works on Windows, macOS, and Linux +- **Timeout Management**: Configurable execution timeouts +- **Argument Validation**: Optional input sanitization + +## Installation + +```bash +pip install utcp-cli +``` + +## Quick Start + +```python +from utcp.utcp_client import UtcpClient + +# Basic CLI tool +client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "file_tools", + "call_template_type": "cli", + "command_name": "ls -la ${path}" + }] +}) + +result = await client.call_tool("file_tools.list", {"path": "/home"}) +``` + +## Configuration Examples + +### Basic Command +```json +{ + "name": "file_ops", + "call_template_type": "cli", + "command_name": "ls -la ${path}", + "working_dir": "/tmp" +} +``` + +### With Environment Variables +```json +{ + "name": "python_script", + "call_template_type": "cli", + "command_name": "python script.py ${input}", + "env_vars": { + "PYTHONPATH": "/custom/path", + "API_KEY": "${API_KEY}" + } +} +``` + +### Processing JSON with jq +```json +{ + "name": "json_processor", + "call_template_type": "cli", + "command_name": "jq '.data'", + "stdin": "${json_input}", + "timeout": 10 +} +``` + +### Git Operations +```json +{ + "name": "git_tools", + "call_template_type": "cli", + "command_name": "git ${operation} ${args}", + "working_dir": "${repo_path}", + "env_vars": { + "GIT_AUTHOR_NAME": "${author_name}", + "GIT_AUTHOR_EMAIL": "${author_email}" + } +} +``` + +## Security Considerations + +- Commands run in isolated subprocesses +- Environment variables provide secure credential passing +- Working directory restrictions limit file system access +- Input validation prevents command injection + +```json +{ + "name": "safe_grep", + "call_template_type": "cli", + "command_name": "grep ${pattern} ${file}", + "working_dir": "/safe/directory", + "allowed_args": { + "pattern": "^[a-zA-Z0-9_-]+$", + "file": "^[a-zA-Z0-9_./-]+\\.txt$" + } +} +``` + +## Error Handling + +```python +from utcp.exceptions import ToolCallError +import subprocess + +try: + result = await client.call_tool("cli_tool.command", {"arg": "value"}) +except ToolCallError as e: + if isinstance(e.__cause__, subprocess.CalledProcessError): + print(f"Command failed with exit code {e.__cause__.returncode}") + print(f"stderr: {e.__cause__.stderr}") +``` + +## Common Use Cases + +- **File Operations**: ls, find, grep, awk, sed +- **Data Processing**: jq, sort, uniq, cut +- **System Monitoring**: ps, top, df, netstat +- **Development Tools**: git, npm, pip, docker +- **Custom Scripts**: Python, bash, PowerShell scripts + +## Testing CLI Tools + +```python +import pytest +from utcp.utcp_client import UtcpClient + +@pytest.mark.asyncio +async def test_cli_tool(): + client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "test_cli", + "call_template_type": "cli", + "command_name": "echo ${message}" + }] + }) + + result = await client.call_tool("test_cli.echo", {"message": "hello"}) + assert "hello" in result["stdout"] +``` + +## Related Documentation + +- [Main UTCP Documentation](../../../README.md) +- [Core Package Documentation](../../../core/README.md) +- [HTTP Plugin](../http/README.md) +- [MCP Plugin](../mcp/README.md) +- [Text Plugin](../text/README.md) + +## Examples + +For complete examples, see the [UTCP examples repository](https://github.com/universal-tool-calling-protocol/utcp-examples). diff --git a/plugins/communication_protocols/cli/src/utcp_cli/cli_call_template.py b/plugins/communication_protocols/cli/src/utcp_cli/cli_call_template.py index 3d83508..fb4badf 100644 --- a/plugins/communication_protocols/cli/src/utcp_cli/cli_call_template.py +++ b/plugins/communication_protocols/cli/src/utcp_cli/cli_call_template.py @@ -8,17 +8,54 @@ class CliCallTemplate(CallTemplate): """REQUIRED - Call template configuration for Command Line Interface tools. + Call template configuration for Command Line Interface (CLI) tools. - Enables execution of command-line tools and programs as UTCP providers. - Supports environment variable injection and custom working directories. + This class defines the configuration for executing command-line tools and + programs as UTCP tool providers. It supports environment variable injection, + custom working directories, and defines the command to be executed. Attributes: - call_template_type: Always "cli" for CLI providers. - command_name: The name or path of the command to execute. - env_vars: Optional environment variables to set during command execution. - working_dir: Optional custom working directory for command execution. - auth: Always None - CLI providers don't support authentication. + call_template_type: The type of the call template. Must be "cli". + command_name: The command or path of the program to execute. It can + contain placeholders for arguments that will be substituted at + runtime (e.g., `${arg_name}`). + env_vars: A dictionary of environment variables to set for the command's + execution context. Values can be static strings or placeholders for + variables from the UTCP client's variable substitutor. + working_dir: The working directory from which to run the command. If not + provided, it defaults to the current process's working directory. + auth: Authentication details. Not applicable to the CLI protocol, so it + is always None. + + Examples: + Basic CLI command: + ```json + { + "name": "list_files_tool", + "call_template_type": "cli", + "command_name": "ls -la", + "working_dir": "/tmp" + } + ``` + + Command with environment variables and argument placeholders: + ```json + { + "name": "python_script_tool", + "call_template_type": "cli", + "command_name": "python script.py --input ${input_file}", + "env_vars": { + "PYTHONPATH": "/custom/path", + "API_KEY": "${API_KEY_VAR}" + } + } + ``` + + Security Considerations: + - Commands are executed in a subprocess. Ensure that the commands + specified are from a trusted source. + - Avoid passing unsanitized user input directly into the command string. + Use tool argument validation where possible. """ call_template_type: Literal["cli"] = "cli" @@ -34,16 +71,39 @@ class CliCallTemplate(CallTemplate): class CliCallTemplateSerializer(Serializer[CliCallTemplate]): """REQUIRED - Serializer for CliCallTemplate.""" + Serializer for converting between `CliCallTemplate` and dictionary representations. + + This class handles the serialization and deserialization of `CliCallTemplate` + objects, ensuring that they can be correctly represented as dictionaries and + reconstructed from them, with validation. + """ def to_dict(self, obj: CliCallTemplate) -> dict: """REQUIRED - Converts a CliCallTemplate to a dictionary.""" + Converts a `CliCallTemplate` instance to its dictionary representation. + + Args: + obj: The `CliCallTemplate` instance to serialize. + + Returns: + A dictionary representing the `CliCallTemplate`. + """ return obj.model_dump() def validate_dict(self, obj: dict) -> CliCallTemplate: """REQUIRED - Validates a dictionary and returns a CliCallTemplate.""" + Validates a dictionary and constructs a `CliCallTemplate` instance. + + Args: + obj: The dictionary to validate and deserialize. + + Returns: + A `CliCallTemplate` instance. + + Raises: + UtcpSerializerValidationError: If the dictionary is not a valid + representation of a `CliCallTemplate`. + """ try: return CliCallTemplate.model_validate(obj) except Exception as e: diff --git a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py index abb1690..f1a6fed 100644 --- a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py +++ b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py @@ -1,23 +1,21 @@ -"""Command Line Interface (CLI) transport for UTCP client. +"""Command Line Interface (CLI) communication protocol for the UTCP client. -This module provides the CLI transport implementation that enables UTCP clients -to interact with command-line tools and processes. It handles tool discovery -through startup commands, tool execution with proper argument formatting, -and output processing with JSON parsing capabilities. +This module provides an implementation of the `CommunicationProtocol` interface +that enables the UTCP client to interact with command-line tools. It supports +discovering tools by executing a command and parsing its output for a UTCP +manual, as well as calling those tools with arguments. Key Features: - - Asynchronous command execution with timeout handling - - Tool discovery via startup commands that output UTCP manuals - - Flexible argument formatting for command-line flags - - Environment variable support for authentication and configuration - - JSON output parsing with fallback to raw text - - Cross-platform command parsing (Windows/Unix) - - Working directory control for command execution - -Security: - - Command execution is isolated through subprocess - - Environment variables can be controlled per provider - - Working directory can be restricted + - Asynchronous execution of shell commands. + - Tool discovery by running a command that outputs a UTCP manual. + - Flexible argument formatting for different CLI conventions. + - Support for environment variables and custom working directories. + - Automatic parsing of JSON output with a fallback to raw text. + - Cross-platform command parsing for Windows and Unix-like systems. + +Security Considerations: + Executing arbitrary command-line tools can be dangerous. This protocol + should only be used with trusted tools. """ import asyncio import json @@ -45,33 +43,17 @@ class CliCommunicationProtocol(CommunicationProtocol): """REQUIRED - Transport implementation for CLI-based tool providers. - - Handles communication with command-line tools by executing processes - and managing their input/output. Supports both tool discovery and - execution phases with comprehensive error handling and timeout management. - - Features: - - Asynchronous subprocess execution with proper cleanup - - Tool discovery through startup commands returning UTCP manuals - - Flexible argument formatting for various CLI conventions - - Environment variable injection for authentication - - JSON output parsing with graceful fallback to text - - Cross-platform command parsing and execution - - Configurable working directories and timeouts - - Process lifecycle management with proper termination - - Architecture: - CLI tools are discovered by executing the provider's command_name - and parsing the output for UTCP manual JSON. Tool calls execute - the same command with formatted arguments and return processed output. - - Attributes: - _log: Logger function for debugging and error reporting. + Communication protocol for interacting with CLI-based tool providers. + + This class implements the `CommunicationProtocol` interface to handle + communication with command-line tools. It discovers tools by executing a + command specified in a `CliCallTemplate` and parsing the output for a UTCP + manual. It also executes tool calls by running the corresponding command + with the provided arguments. """ def __init__(self): - """Initialize the CLI transport.""" + """Initializes the `CliCommunicationProtocol`.""" def _log_info(self, message: str): """Log informational messages.""" @@ -163,9 +145,24 @@ async def _execute_command( async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult: """REQUIRED - Register a CLI manual and discover its tools. - - Executes the call template's command_name and looks for a UTCP manual JSON in the output. + Registers a CLI-based manual and discovers its tools. + + This method executes the command specified in the `CliCallTemplate`'s + `command_name` field. It then attempts to parse the command's output + (stdout) as a UTCP manual in JSON format. + + Args: + caller: The UTCP client instance that is calling this method. + manual_call_template: The `CliCallTemplate` containing the details for + tool discovery, such as the command to run. + + Returns: + A `RegisterManualResult` object indicating whether the registration + was successful and containing the discovered tools. + + Raises: + ValueError: If the `manual_call_template` is not an instance of + `CliCallTemplate` or if `command_name` is not set. """ if not isinstance(manual_call_template, CliCallTemplate): raise ValueError("CliCommunicationProtocol can only be used with CliCallTemplate") @@ -247,7 +244,15 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None: """REQUIRED - Deregister a CLI manual (no-op).""" + Deregisters a CLI manual. + + For the CLI protocol, this is a no-op as there are no persistent + connections to terminate. + + Args: + caller: The UTCP client instance that is calling this method. + manual_call_template: The call template of the manual to deregister. + """ if isinstance(manual_call_template, CliCallTemplate): self._log_info( f"Deregistering CLI manual '{manual_call_template.name}' (no-op)" @@ -410,23 +415,27 @@ def _parse_tool_data(self, data: Any, provider_name: str) -> List[Tool]: async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any: """REQUIRED - Call a CLI tool. - - Executes the command specified by provider.command_name with the provided arguments. - + Calls a CLI tool by executing its command. + + This method constructs and executes the command specified in the + `CliCallTemplate`. It formats the provided `tool_args` as command-line + arguments and runs the command in a subprocess. + Args: - caller: The UTCP client that is calling this method. - tool_name: Name of the tool to call - tool_args: Arguments for the tool call - tool_call_template: The CliCallTemplate for the tool - + caller: The UTCP client instance that is calling this method. + tool_name: The name of the tool to call. + tool_args: A dictionary of arguments for the tool call. + tool_call_template: The `CliCallTemplate` for the tool. + Returns: - The output from the command execution based on exit code: - - If exit code is 0: stdout (parsed as JSON if possible, otherwise raw string) - - If exit code is not 0: stderr - + The result of the command execution. If the command exits with a code + of 0, it returns the content of stdout. If the exit code is non-zero, + it returns the content of stderr. The output is parsed as JSON if + possible; otherwise, it is returned as a raw string. + Raises: - ValueError: If provider is not a CliProvider or command_name is not set + ValueError: If `tool_call_template` is not an instance of + `CliCallTemplate` or if `command_name` is not set. """ if not isinstance(tool_call_template, CliCallTemplate): raise ValueError("CliCommunicationProtocol can only be used with CliCallTemplate") @@ -483,13 +492,9 @@ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], too async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]: """REQUIRED - Streaming calls are not supported for CLI protocol.""" - raise NotImplementedError("Streaming is not supported by the CLI communication protocol.") - - async def close(self) -> None: - """ - Close the transport. - - This is a no-op for CLI transports since they don't maintain connections. + Streaming calls are not supported for the CLI protocol. + + Raises: + NotImplementedError: Always, as this functionality is not supported. """ - self._log_info("Closing CLI transport (no-op)") + raise NotImplementedError("Streaming is not supported by the CLI communication protocol.") diff --git a/plugins/communication_protocols/http/README.md b/plugins/communication_protocols/http/README.md index 8febb5a..5f66bb2 100644 --- a/plugins/communication_protocols/http/README.md +++ b/plugins/communication_protocols/http/README.md @@ -1 +1,144 @@ -Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file +# UTCP HTTP Plugin + +[![PyPI Downloads](https://static.pepy.tech/badge/utcp-http)](https://pepy.tech/projects/utcp-http) + +HTTP communication protocol plugin for UTCP, supporting REST APIs, Server-Sent Events (SSE), and streaming HTTP. + +## Features + +- **HTTP/REST APIs**: Full support for GET, POST, PUT, DELETE, PATCH methods +- **Authentication**: API key, Basic Auth, OAuth2 support +- **Server-Sent Events (SSE)**: Real-time event streaming +- **Streaming HTTP**: Large response handling with chunked transfer +- **OpenAPI Integration**: Automatic tool generation from OpenAPI specs +- **Path Parameters**: URL templating with `{parameter}` syntax +- **Custom Headers**: Static and dynamic header support + +## Installation + +```bash +pip install utcp-http +``` + +## Quick Start + +```python +from utcp.utcp_client import UtcpClient + +# Basic HTTP API +client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "api_service", + "call_template_type": "http", + "url": "https://api.example.com/users/{user_id}", + "http_method": "GET" + }] +}) + +result = await client.call_tool("api_service.get_user", {"user_id": "123"}) +``` + +## Configuration Examples + +### Basic HTTP Request +```json +{ + "name": "my_api", + "call_template_type": "http", + "url": "https://api.example.com/data", + "http_method": "GET" +} +``` + +### With API Key Authentication +```json +{ + "name": "secure_api", + "call_template_type": "http", + "url": "https://api.example.com/data", + "http_method": "POST", + "auth": { + "auth_type": "api_key", + "api_key": "${API_KEY}", + "var_name": "X-API-Key", + "location": "header" + } +} +``` + +### OAuth2 Authentication +```json +{ + "name": "oauth_api", + "call_template_type": "http", + "url": "https://api.example.com/data", + "auth": { + "auth_type": "oauth2", + "client_id": "${CLIENT_ID}", + "client_secret": "${CLIENT_SECRET}", + "token_url": "https://auth.example.com/token" + } +} +``` + +### Server-Sent Events (SSE) +```json +{ + "name": "event_stream", + "call_template_type": "sse", + "url": "https://api.example.com/events", + "event_type": "message", + "reconnect": true +} +``` + +### Streaming HTTP +```json +{ + "name": "large_data", + "call_template_type": "streamable_http", + "url": "https://api.example.com/download", + "chunk_size": 8192 +} +``` + +## OpenAPI Integration + +Automatically generate UTCP tools from OpenAPI specifications: + +```python +from utcp_http.openapi_converter import OpenApiConverter + +converter = OpenApiConverter() +manual = await converter.convert_openapi_to_manual( + "https://api.example.com/openapi.json" +) + +client = await UtcpClient.create() +await client.register_manual(manual) +``` + +## Error Handling + +```python +from utcp.exceptions import ToolCallError +import httpx + +try: + result = await client.call_tool("api.get_data", {"id": "123"}) +except ToolCallError as e: + if isinstance(e.__cause__, httpx.HTTPStatusError): + print(f"HTTP {e.__cause__.response.status_code}: {e.__cause__.response.text}") +``` + +## Related Documentation + +- [Main UTCP Documentation](../../../README.md) +- [Core Package Documentation](../../../core/README.md) +- [CLI Plugin](../cli/README.md) +- [MCP Plugin](../mcp/README.md) +- [Text Plugin](../text/README.md) + +## Examples + +For complete examples, see the [UTCP examples repository](https://github.com/universal-tool-calling-protocol/utcp-examples). diff --git a/plugins/communication_protocols/http/src/utcp_http/http_call_template.py b/plugins/communication_protocols/http/src/utcp_http/http_call_template.py index 73e4d64..b3a9e70 100644 --- a/plugins/communication_protocols/http/src/utcp_http/http_call_template.py +++ b/plugins/communication_protocols/http/src/utcp_http/http_call_template.py @@ -15,6 +15,70 @@ class HttpCallTemplate(CallTemplate): parameters using {parameter_name} syntax. All tool arguments not mapped to URL body, headers or query pattern parameters are passed as query parameters using '?arg_name={arg_value}'. + Configuration Examples: + Basic HTTP GET request: + ```json + { + "name": "my_rest_api", + "call_template_type": "http", + "url": "https://api.example.com/users/{user_id}", + "http_method": "GET" + } + ``` + + POST with authentication: + ```json + { + "name": "secure_api", + "call_template_type": "http", + "url": "https://api.example.com/users", + "http_method": "POST", + "content_type": "application/json", + "auth": { + "auth_type": "api_key", + "api_key": "Bearer ${API_KEY}", + "var_name": "Authorization", + "location": "header" + }, + "headers": { + "X-Custom-Header": "value" + }, + "body_field": "body", + "header_fields": ["user_id"] + } + ``` + + OAuth2 authentication: + ```json + { + "name": "oauth_api", + "call_template_type": "http", + "url": "https://api.example.com/data", + "http_method": "GET", + "auth": { + "auth_type": "oauth2", + "client_id": "${CLIENT_ID}", + "client_secret": "${CLIENT_SECRET}", + "token_url": "https://auth.example.com/token" + } + } + ``` + + Basic authentication: + ```json + { + "name": "basic_auth_api", + "call_template_type": "http", + "url": "https://api.example.com/secure", + "http_method": "GET", + "auth": { + "auth_type": "basic", + "username": "${USERNAME}", + "password": "${PASSWORD}" + } + } + ``` + Attributes: call_template_type: Always "http" for HTTP providers. http_method: The HTTP method to use for requests. diff --git a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py index 1a6b679..be20fe6 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -5,13 +5,13 @@ mapping, and proper tool creation from REST API specifications. Key Features: - - OpenAPI 2.0 and 3.0 specification support - - Automatic JSON reference ($ref) resolution - - Authentication scheme mapping (API key, Basic, OAuth2) - - Input/output schema extraction from OpenAPI schemas - - URL path parameter handling - - Request body and header field mapping - - Provider name generation from specification metadata + - OpenAPI 2.0 and 3.0 specification support. + - Automatic JSON reference ($ref) resolution. + - Authentication scheme mapping (API key, Basic, OAuth2). + - Input/output schema extraction from OpenAPI schemas. + - URL path parameter handling. + - Request body and header field mapping. + - Call template name generation from specification metadata. The converter creates UTCP tools that can be used to interact with REST APIs defined by OpenAPI specifications, providing a bridge between OpenAPI and UTCP. @@ -38,14 +38,42 @@ class OpenApiConverter: a UTCP tool with appropriate input/output schemas. Features: - - Complete OpenAPI specification parsing - - Recursive JSON reference ($ref) resolution - - Authentication scheme conversion (API key, Basic, OAuth2) - - Input parameter and request body handling - - Response schema extraction - - URL template and path parameter support - - Provider name normalization - - Placeholder variable generation for configuration + - Complete OpenAPI specification parsing. + - Recursive JSON reference ($ref) resolution. + - Authentication scheme conversion (API key, Basic, OAuth2). + - Input parameter and request body handling. + - Response schema extraction. + - URL template and path parameter support. + - Call template name normalization. + - Placeholder variable generation for configuration. + + Usage Examples: + Basic OpenAPI conversion: + ```python + from utcp_http.openapi_converter import OpenApiConverter + + # Assuming you have a method to fetch and parse the spec + openapi_spec = fetch_and_parse_spec("https://api.example.com/openapi.json") + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + # Use the generated manual with a UTCP client + # client = await UtcpClient.create() + # await client.register_manual(manual) + ``` + + Converting local OpenAPI file: + ```python + import yaml + + converter = OpenApiConverter() + with open("api_spec.yaml", "r") as f: + spec_content = yaml.safe_load(f) + + converter = OpenApiConverter(spec_content) + manual = converter.convert() + ``` Architecture: The converter works by iterating through all paths and operations @@ -60,14 +88,14 @@ class OpenApiConverter: """ def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, call_template_name: Optional[str] = None): - """Initialize the OpenAPI converter. + """Initializes the OpenAPI converter. Args: openapi_spec: Parsed OpenAPI specification as a dictionary. spec_url: Optional URL where the specification was retrieved from. Used for base URL determination if servers are not specified. - call_template_name: Optional custom name for the call_template if - the specification title is not provided. + call_template_name: Optional custom name for the call_template if + the specification title is not provided. """ self.spec = openapi_spec self.spec_url = spec_url @@ -99,7 +127,15 @@ def _get_placeholder(self, placeholder_name: str) -> str: def convert(self) -> UtcpManual: """REQUIRED - Parses the OpenAPI specification and returns a UtcpManual.""" + Converts the loaded OpenAPI specification into a UtcpManual. + + This is the main entry point for the conversion process. It iterates through + the paths and operations in the specification, creating a UTCP tool for each + one. + + Returns: + A UtcpManual object containing all the tools generated from the spec. + """ self.placeholder_counter = 0 tools = [] servers = self.spec.get("servers") diff --git a/plugins/communication_protocols/mcp/README.md b/plugins/communication_protocols/mcp/README.md index 8febb5a..0aa06f4 100644 --- a/plugins/communication_protocols/mcp/README.md +++ b/plugins/communication_protocols/mcp/README.md @@ -1 +1,253 @@ -Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file +# UTCP MCP Plugin + +[![PyPI Downloads](https://static.pepy.tech/badge/utcp-mcp)](https://pepy.tech/projects/utcp-mcp) + +Model Context Protocol (MCP) interoperability plugin for UTCP, enabling seamless integration with existing MCP servers. + +## Features + +- **MCP Server Integration**: Connect to existing MCP servers +- **Stdio Transport**: Local process-based MCP servers +- **HTTP Transport**: Remote MCP server connections +- **OAuth2 Authentication**: Secure authentication for HTTP servers +- **Migration Support**: Gradual migration from MCP to UTCP +- **Tool Discovery**: Automatic tool enumeration from MCP servers +- **Session Management**: Efficient connection handling + +## Installation + +```bash +pip install utcp-mcp +``` + +## Quick Start + +```python +from utcp.utcp_client import UtcpClient + +# Connect to MCP server +client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "mcp_server", + "call_template_type": "mcp", + "config": { + "mcpServers": { + "filesystem": { + "command": "node", + "args": ["mcp-server.js"] + } + } + } + }] +}) + +# Call MCP tool through UTCP +result = await client.call_tool("mcp_server.filesystem.read_file", { + "path": "/data/file.txt" +}) +``` + +## Configuration Examples + +### Stdio Transport (Local Process) +```json +{ + "name": "local_mcp", + "call_template_type": "mcp", + "config": { + "mcpServers": { + "filesystem": { + "command": "python", + "args": ["-m", "mcp_filesystem_server"], + "env": {"LOG_LEVEL": "INFO"} + } + } + } +} +``` + +### HTTP Transport (Remote Server) +```json +{ + "name": "remote_mcp", + "call_template_type": "mcp", + "config": { + "mcpServers": { + "api_server": { + "transport": "http", + "url": "https://mcp.example.com" + } + } + } +} +``` + +### With OAuth2 Authentication +```json +{ + "name": "secure_mcp", + "call_template_type": "mcp", + "config": { + "mcpServers": { + "secure_server": { + "transport": "http", + "url": "https://mcp.example.com" + } + } + }, + "auth": { + "auth_type": "oauth2", + "token_url": "https://auth.example.com/token", + "client_id": "${CLIENT_ID}", + "client_secret": "${CLIENT_SECRET}", + "scope": "read:tools" + } +} +``` + +### Multiple MCP Servers +```json +{ + "name": "multi_mcp", + "call_template_type": "mcp", + "config": { + "mcpServers": { + "filesystem": { + "command": "python", + "args": ["-m", "mcp_filesystem"] + }, + "database": { + "command": "node", + "args": ["mcp-db-server.js"], + "cwd": "/app/mcp-servers" + } + } + } +} +``` + +## Migration Scenarios + +### Gradual Migration from MCP to UTCP + +**Phase 1: MCP Integration** +```python +# Use existing MCP servers through UTCP +client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "legacy_mcp", + "call_template_type": "mcp", + "config": {"mcpServers": {"server": {...}}} + }] +}) +``` + +**Phase 2: Mixed Environment** +```python +# Mix MCP and native UTCP tools +client = await UtcpClient.create(config={ + "manual_call_templates": [ + { + "name": "legacy_mcp", + "call_template_type": "mcp", + "config": {"mcpServers": {"old_server": {...}}} + }, + { + "name": "new_api", + "call_template_type": "http", + "url": "https://api.example.com/utcp" + } + ] +}) +``` + +**Phase 3: Full UTCP** +```python +# Pure UTCP implementation +client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "native_utcp", + "call_template_type": "http", + "url": "https://api.example.com/utcp" + }] +}) +``` + +## Debugging and Troubleshooting + +### Enable Debug Logging +```python +import logging +logging.getLogger('utcp.mcp').setLevel(logging.DEBUG) + +try: + client = await UtcpClient.create(config=mcp_config) + tools = await client.list_tools() +except TimeoutError: + print("MCP server connection timed out") +``` + +### List Available Tools +```python +# Discover tools from MCP server +tools = await client.list_tools() +print(f"Available tools: {[tool.name for tool in tools]}") +``` + +### Connection Testing +```python +@pytest.mark.asyncio +async def test_mcp_integration(): + client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "test_mcp", + "call_template_type": "mcp", + "config": { + "mcpServers": { + "test": { + "command": "python", + "args": ["-m", "test_mcp_server"] + } + } + } + }] + }) + + tools = await client.list_tools() + assert len(tools) > 0 + + result = await client.call_tool("test_mcp.echo", {"message": "test"}) + assert result["message"] == "test" +``` + +## Error Handling + +```python +from utcp.exceptions import ToolCallError + +try: + result = await client.call_tool("mcp_server.tool", {"arg": "value"}) +except ToolCallError as e: + print(f"MCP tool call failed: {e}") + # Check if it's a connection issue, authentication error, etc. +``` + +## Performance Considerations + +- **Session Reuse**: MCP plugin reuses connections when possible +- **Timeout Configuration**: Set appropriate timeouts for MCP operations +- **Resource Cleanup**: Sessions are automatically cleaned up +- **Concurrent Calls**: Multiple tools can be called concurrently + +## Related Documentation + +- [Main UTCP Documentation](../../../README.md) +- [Core Package Documentation](../../../core/README.md) +- [HTTP Plugin](../http/README.md) +- [CLI Plugin](../cli/README.md) +- [Text Plugin](../text/README.md) +- [MCP Specification](https://modelcontextprotocol.io/) + +## Examples + +For complete examples, see the [UTCP examples repository](https://github.com/universal-tool-calling-protocol/utcp-examples). diff --git a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_call_template.py b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_call_template.py index 9d73d4e..0ecdedb 100644 --- a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_call_template.py +++ b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_call_template.py @@ -37,6 +37,87 @@ class McpCallTemplate(CallTemplate): interfaces. Supports both stdio (local process) and HTTP (remote) transport methods. + Configuration Examples: + Basic MCP server with stdio transport: + ```json + { + "name": "mcp_server", + "call_template_type": "mcp", + "config": { + "mcpServers": { + "filesystem": { + "command": "node", + "args": ["mcp-server.js"], + "env": {"NODE_ENV": "production"} + } + } + } + } + ``` + + MCP server with working directory: + ```json + { + "name": "mcp_tools", + "call_template_type": "mcp", + "config": { + "mcpServers": { + "tools": { + "command": "python", + "args": ["-m", "mcp_server"], + "cwd": "/app/mcp", + "env": { + "PYTHONPATH": "/app", + "LOG_LEVEL": "INFO" + } + } + } + } + } + ``` + + MCP server with OAuth2 authentication: + ```json + { + "name": "secure_mcp", + "call_template_type": "mcp", + "config": { + "mcpServers": { + "secure_server": { + "transport": "http", + "url": "https://mcp.example.com" + } + } + }, + "auth": { + "auth_type": "oauth2", + "token_url": "https://auth.example.com/token", + "client_id": "${CLIENT_ID}", + "client_secret": "${CLIENT_SECRET}", + "scope": "read:tools" + } + } + ``` + + Migration Examples: + During migration (UTCP with MCP): + ```python + # UTCP Client with MCP plugin + client = await UtcpClient.create() + result = await client.call_tool("filesystem.read_file", { + "path": "/data/file.txt" + }) + ``` + + After migration (Pure UTCP): + ```python + # UTCP Client with native protocol + client = await UtcpClient.create() + result = await client.call_tool("filesystem.read_file", { + "path": "/data/file.txt" + }) + ``` + Attributes: call_template_type: Always "mcp" for MCP providers. config: Configuration object containing MCP server definitions. diff --git a/plugins/communication_protocols/text/README.md b/plugins/communication_protocols/text/README.md index 8febb5a..53a7fd3 100644 --- a/plugins/communication_protocols/text/README.md +++ b/plugins/communication_protocols/text/README.md @@ -1 +1,126 @@ -Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file +# UTCP Text Plugin + +[![PyPI Downloads](https://static.pepy.tech/badge/utcp-text)](https://pepy.tech/projects/utcp-text) + +A simple, file-based resource plugin for UTCP. This plugin allows you to define tools that return the content of a specified local file. + +## Features + +- **Local File Content**: Define tools that read and return the content of local files. +- **UTCP Manual Discovery**: Load tool definitions from local UTCP manual files in JSON or YAML format. +- **Static & Simple**: Ideal for returning mock data, configuration, or any static text content from a file. +- **Version Control**: Tool definitions and their corresponding content files can be versioned with your code. +- **No Authentication**: Designed for simple, local file access without authentication. + +## Installation + +```bash +pip install utcp-text +``` + +## How It Works + +The Text plugin operates in two main ways: + +1. **Tool Discovery (`register_manual`)**: It can read a standard UTCP manual file (e.g., `my-tools.json`) to learn about available tools. This is how the `UtcpClient` discovers what tools can be called. +2. **Tool Execution (`call_tool`)**: When you call a tool, the plugin looks at the `tool_call_template` associated with that tool. It expects a `text` template, and it will read and return the entire content of the `file_path` specified in that template. + +**Important**: The `call_tool` function **does not** use the arguments you pass to it. It simply returns the full content of the file defined in the tool's template. + +## Quick Start + +Here is a complete example demonstrating how to define and use a tool that returns the content of a file. + +### 1. Create a Content File + +First, create a file with some content that you want your tool to return. + +`./mock_data/user.json`: +```json +{ + "id": 123, + "name": "John Doe", + "email": "john.doe@example.com" +} +``` + +### 2. Create a UTCP Manual + +Next, define a UTCP manual that describes your tool. The `tool_call_template` must be of type `text` and point to the content file you just created. + +`./manuals/local_tools.json`: +```json +{ + "manual_version": "1.0.0", + "utcp_version": "1.0.1", + "tools": [ + { + "name": "get_mock_user", + "description": "Returns a mock user profile from a local file.", + "tool_call_template": { + "call_template_type": "text", + "file_path": "./mock_data/user.json" + } + } + ] +} +``` + +### 3. Use the Tool in Python + +Finally, use the `UtcpClient` to load the manual and call the tool. + +```python +import asyncio +from utcp.utcp_client import UtcpClient + +async def main(): + # Create a client, providing the path to the manual. + # The text plugin is used automatically for the "text" call_template_type. + client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "local_file_tools", + "call_template_type": "text", + "file_path": "./manuals/local_tools.json" + }] + }) + + # List the tools to confirm it was loaded + tools = await client.list_tools() + print("Available tools:", [tool.name for tool in tools]) + + # Call the tool. The result will be the content of './mock_data/user.json' + result = await client.call_tool("local_file_tools.get_mock_user", {}) + + print("\nTool Result:") + print(result) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Expected Output: + +``` +Available tools: ['local_file_tools.get_mock_user'] + +Tool Result: +{ + "id": 123, + "name": "John Doe", + "email": "john.doe@example.com" +} +``` + +## Use Cases + +- **Mocking**: Return mock data for tests or local development without needing a live server. +- **Configuration**: Load static configuration files as tool outputs. +- **Templates**: Retrieve text templates (e.g., for emails or reports). + +## Related Documentation + +- [Main UTCP Documentation](../../../README.md) +- [Core Package Documentation](../../../core/README.md) +- [HTTP Plugin](../http/README.md) - For calling real web APIs. +- [CLI Plugin](../cli/README.md) - For executing command-line tools. From aa6f7e34537dc9a29de412b4e96d6ee5610bbe96 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Sun, 7 Sep 2025 10:50:21 +0200 Subject: [PATCH 19/21] Copy Readme to core --- core/README.md | 523 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 core/README.md diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..1adedc7 --- /dev/null +++ b/core/README.md @@ -0,0 +1,523 @@ +# Universal Tool Calling Protocol (UTCP) 1.0.1 + +[![Follow Org](https://img.shields.io/github/followers/universal-tool-calling-protocol?label=Follow%20Org&logo=github)](https://github.com/universal-tool-calling-protocol) +[![PyPI Downloads](https://static.pepy.tech/badge/utcp)](https://pepy.tech/projects/utcp) +[![License](https://img.shields.io/github/license/universal-tool-calling-protocol/python-utcp)](https://github.com/universal-tool-calling-protocol/python-utcp/blob/main/LICENSE) +[![CDTM S23](https://img.shields.io/badge/CDTM-S23-0b84f3)](https://cdtm.com/) + +## Introduction + +The Universal Tool Calling Protocol (UTCP) is a secure, scalable standard for defining and interacting with tools across a wide variety of communication protocols. UTCP 1.0.0 introduces a modular core with a plugin-based architecture, making it more extensible, testable, and easier to package. + +In contrast to other protocols, UTCP places a strong emphasis on: + +* **Scalability**: UTCP is designed to handle a large number of tools and providers without compromising performance. +* **Extensibility**: A pluggable architecture allows developers to easily add new communication protocols, tool storage mechanisms, and search strategies without modifying the core library. +* **Interoperability**: With a growing ecosystem of protocol plugins (including HTTP, SSE, CLI, and more), UTCP can integrate with almost any existing service or infrastructure. +* **Ease of Use**: The protocol is built on simple, well-defined Pydantic models, making it easy for developers to implement and use. + + +![MCP vs. UTCP](https://github.com/user-attachments/assets/3cadfc19-8eea-4467-b606-66e580b89444) + +## Repository Structure + +This repository contains the complete UTCP Python implementation: + +- **[`core/`](core/)** - Core `utcp` package with foundational components ([README](core/README.md)) +- **[`plugins/communication_protocols/`](plugins/communication_protocols/)** - Protocol-specific plugins: + - [`http/`](plugins/communication_protocols/http/) - HTTP/REST, SSE, streaming, OpenAPI ([README](plugins/communication_protocols/http/README.md)) + - [`cli/`](plugins/communication_protocols/cli/) - Command-line tools ([README](plugins/communication_protocols/cli/README.md)) + - [`mcp/`](plugins/communication_protocols/mcp/) - Model Context Protocol ([README](plugins/communication_protocols/mcp/README.md)) + - [`text/`](plugins/communication_protocols/text/) - File-based tools ([README](plugins/communication_protocols/text/README.md)) + - [`socket/`](plugins/communication_protocols/socket/) - TCP/UDP (🚧 In Progress) + - [`gql/`](plugins/communication_protocols/gql/) - GraphQL (🚧 In Progress) + +## Architecture Overview + +UTCP uses a modular architecture with a core library and protocol plugins: + +### Core Package (`utcp`) + +The [`core/`](core/) directory contains the foundational components: +- **Data Models**: Pydantic models for `Tool`, `CallTemplate`, `UtcpManual`, and `Auth` +- **Client Interface**: Main `UtcpClient` for tool interaction +- **Plugin System**: Extensible interfaces for protocols, repositories, and search +- **Default Implementations**: Built-in tool storage and search strategies + +## Quick Start + +### Installation + +Install the core library and any required protocol plugins: + +```bash +# Install core + HTTP plugin (most common) +pip install utcp utcp-http + +# Install additional plugins as needed +pip install utcp-cli utcp-mcp utcp-text +``` + +### Basic Usage + +```python +from utcp.utcp_client import UtcpClient + +# Create client with HTTP API +client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "my_api", + "call_template_type": "http", + "url": "https://api.example.com/utcp" + }] +}) + +# Call a tool +result = await client.call_tool("my_api.get_data", {"id": "123"}) +``` + +## Protocol Plugins + +UTCP supports multiple communication protocols through dedicated plugins: + +| Plugin | Description | Status | Documentation | +|--------|-------------|--------|---------------| +| [`utcp-http`](plugins/communication_protocols/http/) | HTTP/REST APIs, SSE, streaming | ✅ Stable | [HTTP Plugin README](plugins/communication_protocols/http/README.md) | +| [`utcp-cli`](plugins/communication_protocols/cli/) | Command-line tools | ✅ Stable | [CLI Plugin README](plugins/communication_protocols/cli/README.md) | +| [`utcp-mcp`](plugins/communication_protocols/mcp/) | Model Context Protocol | ✅ Stable | [MCP Plugin README](plugins/communication_protocols/mcp/README.md) | +| [`utcp-text`](plugins/communication_protocols/text/) | Local file-based tools | ✅ Stable | [Text Plugin README](plugins/communication_protocols/text/README.md) | +| [`utcp-socket`](plugins/communication_protocols/socket/) | TCP/UDP protocols | 🚧 In Progress | [Socket Plugin README](plugins/communication_protocols/socket/README.md) | +| [`utcp-gql`](plugins/communication_protocols/gql/) | GraphQL APIs | 🚧 In Progress | [GraphQL Plugin README](plugins/communication_protocols/gql/README.md) | + +For development, you can install the packages in editable mode from the cloned repository: + +```bash +# Clone the repository +git clone https://github.com/universal-tool-calling-protocol/python-utcp.git +cd python-utcp + +# Install the core package in editable mode with dev dependencies +pip install -e "core[dev]" + +# Install a specific protocol plugin in editable mode +pip install -e plugins/communication_protocols/http +``` + +## Migration Guide from 0.x to 1.0.0 + +Version 1.0.0 introduces several breaking changes. Follow these steps to migrate your project. + +1. **Update Dependencies**: Install the new `utcp` core package and the specific protocol plugins you use (e.g., `utcp-http`, `utcp-cli`). +2. **Configuration**: + * **Configuration Object**: `UtcpClient` is initialized with a `UtcpClientConfig` object, dict or a path to a JSON file containing the configuration. + * **Manual Call Templates**: The `providers_file_path` option is removed. Instead of a file path, you now provide a list of `manual_call_templates` directly within the `UtcpClientConfig`. + * **Terminology**: The term `provider` has been replaced with `call_template`, and `provider_type` is now `call_template_type`. + * **Streamable HTTP**: The `call_template_type` `http_stream` has been renamed to `streamable_http`. +3. **Update Imports**: Change your imports to reflect the new modular structure. For example, `from utcp.client.transport_interfaces.http_transport import HttpProvider` becomes `from utcp_http.http_call_template import HttpCallTemplate`. +4. **Tool Search**: If you were using the default search, the new strategy is `TagAndDescriptionWordMatchStrategy`. This is the new default and requires no changes unless you were implementing a custom strategy. +5. **Tool Naming**: Tool names are now namespaced as `manual_name.tool_name`. The client handles this automatically. +6. **Variable Substitution Namespacing**: Variables that are substituted in different `call_templates`, are first namespaced with the name of the manual with the `_` duplicated. So a key in a tool call template called `API_KEY` from the manual `manual_1` would be converted to `manual__1_API_KEY`. + +## Usage Examples + +### 1. Using the UTCP Client + +**`config.json`** (Optional) + +You can define a comprehensive client configuration in a JSON file. All of these fields are optional. + +```json +{ + "variables": { + "openlibrary_URL": "https://openlibrary.org/static/openapi.json" + }, + "load_variables_from": [ + { + "variable_loader_type": "dotenv", + "env_file_path": ".env" + } + ], + "tool_repository": { + "tool_repository_type": "in_memory" + }, + "tool_search_strategy": { + "tool_search_strategy_type": "tag_and_description_word_match" + }, + "manual_call_templates": [ + { + "name": "openlibrary", + "call_template_type": "http", + "http_method": "GET", + "url": "${URL}", + "content_type": "application/json" + }, + ], + "post_processing": [ + { + "tool_post_processor_type": "filter_dict", + "only_include_keys": ["name", "key"], + "only_include_tools": ["openlibrary.read_search_authors_json_search_authors_json_get"] + } + ] +} +``` + +**`client.py`** + +```python +import asyncio +from utcp.utcp_client import UtcpClient +from utcp.data.utcp_client_config import UtcpClientConfig + +async def main(): + # The UtcpClient can be created with a config file path, a dict, or a UtcpClientConfig object. + + # Option 1: Initialize from a config file path + # client_from_file = await UtcpClient.create(config="./config.json") + + # Option 2: Initialize from a dictionary + client_from_dict = await UtcpClient.create(config={ + "variables": { + "openlibrary_URL": "https://openlibrary.org/static/openapi.json" + }, + "load_variables_from": [ + { + "variable_loader_type": "dotenv", + "env_file_path": ".env" + } + ], + "tool_repository": { + "tool_repository_type": "in_memory" + }, + "tool_search_strategy": { + "tool_search_strategy_type": "tag_and_description_word_match" + }, + "manual_call_templates": [ + { + "name": "openlibrary", + "call_template_type": "http", + "http_method": "GET", + "url": "${URL}", + "content_type": "application/json" + } + ], + "post_processing": [ + { + "tool_post_processor_type": "filter_dict", + "only_include_keys": ["name", "key"], + "only_include_tools": ["openlibrary.read_search_authors_json_search_authors_json_get"] + } + ] + }) + + # Option 3: Initialize with a full-featured UtcpClientConfig object + from utcp_http.http_call_template import HttpCallTemplate + from utcp.data.variable_loader import VariableLoaderSerializer + from utcp.interfaces.tool_post_processor import ToolPostProcessorConfigSerializer + + config_obj = UtcpClientConfig( + variables={"openlibrary_URL": "https://openlibrary.org/static/openapi.json"}, + load_variables_from=[ + VariableLoaderSerializer().validate_dict({ + "variable_loader_type": "dotenv", "env_file_path": ".env" + }) + ], + manual_call_templates=[ + HttpCallTemplate( + name="openlibrary", + call_template_type="http", + http_method="GET", + url="${URL}", + content_type="application/json" + ) + ], + post_processing=[ + ToolPostProcessorConfigSerializer().validate_dict({ + "tool_post_processor_type": "filter_dict", + "only_include_keys": ["name", "key"], + "only_include_tools": ["openlibrary.read_search_authors_json_search_authors_json_get"] + }) + ] + ) + client = await UtcpClient.create(config=config_obj) + + # Call a tool. The name is namespaced: `manual_name.tool_name` + result = await client.call_tool( + tool_name="openlibrary.read_search_authors_json_search_authors_json_get", + tool_args={"q": "J. K. Rowling"} + ) + + print(result) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### 2. Providing a UTCP Manual + +A `UTCPManual` describes the tools you offer. The key change is replacing `tool_provider` with `tool_call_template`. + +**`server.py`** + +UTCP decorator version: + +```python +from fastapi import FastAPI +from utcp_http.http_call_template import HttpCallTemplate +from utcp.data.utcp_manual import UtcpManual +from utcp.python_specific_tooling.tool_decorator import utcp_tool + +app = FastAPI() + +# The discovery endpoint returns the tool manual +@app.get("/utcp") +def utcp_discovery(): + return UtcpManual.create_from_decorators(manual_version="1.0.0") + +# The actual tool endpoint +@utcp_tool(tool_call_template=HttpCallTemplate( + name="get_weather", + url=f"https://example.com/api/weather", + http_method="GET" +), tags=["weather"]) +@app.get("/api/weather") +def get_weather(location: str): + return {"temperature": 22.5, "conditions": "Sunny"} +``` + + +No UTCP dependencies server version: + +```python +from fastapi import FastAPI + +app = FastAPI() + +# The discovery endpoint returns the tool manual +@app.get("/utcp") +def utcp_discovery(): + return { + "manual_version": "1.0.0", + "utcp_version": "1.0.1", + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a location", + "tags": ["weather"], + "inputs": { + "type": "object", + "properties": { + "location": {"type": "string"} + } + }, + "outputs": { + "type": "object", + "properties": { + "temperature": {"type": "number"}, + "conditions": {"type": "string"} + } + }, + "tool_call_template": { + "call_template_type": "http", + "url": "https://example.com/api/weather", + "http_method": "GET" + } + } + ] + } + +# The actual tool endpoint +@app.get("/api/weather") +def get_weather(location: str): + return {"temperature": 22.5, "conditions": "Sunny"} +``` + +### 3. Full examples + +You can find full examples in the [examples repository](https://github.com/universal-tool-calling-protocol/utcp-examples). + +## Protocol Specification + +### `UtcpManual` and `Tool` Models + +The `tool_provider` object inside a `Tool` has been replaced by `tool_call_template`. + +```json +{ + "manual_version": "string", + "utcp_version": "string", + "tools": [ + { + "name": "string", + "description": "string", + "inputs": { ... }, + "outputs": { ... }, + "tags": ["string"], + "tool_call_template": { + "call_template_type": "http", + "url": "https://...", + "http_method": "GET" + } + } + ] +} +``` + +## Call Template Configuration Examples + +Configuration examples for each protocol. Remember to replace `provider_type` with `call_template_type`. + +### HTTP Call Template + +```json +{ + "name": "my_rest_api", + "call_template_type": "http", // Required + "url": "https://api.example.com/users/{user_id}", // Required + "http_method": "POST", // Required, default: "GET" + "content_type": "application/json", // Optional, default: "application/json" + "auth": { // Optional, example using ApiKeyAuth for a Bearer token. The client must prepend "Bearer " to the token. + "auth_type": "api_key", + "api_key": "Bearer $API_KEY", // Required + "var_name": "Authorization", // Optional, default: "X-Api-Key" + "location": "header" // Optional, default: "header" + }, + "headers": { // Optional + "X-Custom-Header": "value" + }, + "body_field": "body", // Optional, default: "body" + "header_fields": ["user_id"] // Optional +} +``` + +### SSE (Server-Sent Events) Call Template + +```json +{ + "name": "my_sse_stream", + "call_template_type": "sse", // Required + "url": "https://api.example.com/events", // Required + "event_type": "message", // Optional + "reconnect": true, // Optional, default: true + "retry_timeout": 30000, // Optional, default: 30000 (ms) + "auth": { // Optional, example using BasicAuth + "auth_type": "basic", + "username": "${USERNAME}", // Required + "password": "${PASSWORD}" // Required + }, + "headers": { // Optional + "X-Client-ID": "12345" + }, + "body_field": null, // Optional + "header_fields": [] // Optional +} +``` + +### Streamable HTTP Call Template + +Note the name change from `http_stream` to `streamable_http`. + +```json +{ + "name": "streaming_data_source", + "call_template_type": "streamable_http", // Required + "url": "https://api.example.com/stream", // Required + "http_method": "POST", // Optional, default: "GET" + "content_type": "application/octet-stream", // Optional, default: "application/octet-stream" + "chunk_size": 4096, // Optional, default: 4096 + "timeout": 60000, // Optional, default: 60000 (ms) + "auth": null, // Optional + "headers": {}, // Optional + "body_field": "data", // Optional + "header_fields": [] // Optional +} +``` + +### CLI Call Template + +```json +{ + "name": "my_cli_tool", + "call_template_type": "cli", // Required + "command_name": "my-command --utcp", // Required + "env_vars": { // Optional + "MY_VAR": "my_value" + }, + "working_dir": "/path/to/working/directory", // Optional + "auth": null // Optional (always null for CLI) +} +``` + +### Text Call Template + +```json +{ + "name": "my_text_manual", + "call_template_type": "text", // Required + "file_path": "./manuals/my_manual.json", // Required + "auth": null // Optional (always null for Text) +} +``` + +### MCP (Model Context Protocol) Call Template + +```json +{ + "name": "my_mcp_server", + "call_template_type": "mcp", // Required + "config": { // Required + "mcpServers": { + "server_name": { + "transport": "stdio", + "command": ["python", "-m", "my_mcp_server"] + } + } + }, + "auth": { // Optional, example using OAuth2 + "auth_type": "oauth2", + "token_url": "https://auth.example.com/token", // Required + "client_id": "${CLIENT_ID}", // Required + "client_secret": "${CLIENT_SECRET}", // Required + "scope": "read:tools" // Optional + } +} +``` + +## Testing + +The testing structure has been updated to reflect the new core/plugin split. + +### Running Tests + +To run all tests for the core library and all plugins: +```bash +# Ensure you have installed all dev dependencies +python -m pytest +``` + +To run tests for a specific package (e.g., the core library): +```bash +python -m pytest core/tests/ +``` + +To run tests for a specific plugin (e.g., HTTP): +```bash +python -m pytest plugins/communication_protocols/http/tests/ -v +``` + +To run tests with coverage: +```bash +python -m pytest --cov=utcp --cov-report=xml +``` + +## Build + +The build process now involves building each package (`core` and `plugins`) separately if needed, though they are published to PyPI independently. + +1. Create and activate a virtual environment. +2. Install build dependencies: `pip install build`. +3. Navigate to the package directory (e.g., `cd core`). +4. Run the build: `python -m build`. +5. The distributable files (`.whl` and `.tar.gz`) will be in the `dist/` directory. + +## [Contributors](https://www.utcp.io/about) From d8464a0f212d34dd902ce9aafe6f04d6a0db9a41 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Sun, 7 Sep 2025 11:25:53 +0200 Subject: [PATCH 20/21] default logging via basicConfig --- core/src/utcp/__init__.py | 11 ++++------- .../cli/src/utcp_cli/cli_communication_protocol.py | 12 +++++------- .../gql/src/utcp_gql/gql_communication_protocol.py | 12 +++++------- .../src/utcp_http/http_communication_protocol.py | 11 +++++------ .../http/src/utcp_http/sse_communication_protocol.py | 11 +++++------ .../streamable_http_communication_protocol.py | 11 +++++------ .../mcp/src/utcp_mcp/mcp_communication_protocol.py | 12 +++++------- .../src/utcp_socket/tcp_communication_protocol.py | 11 +++++------ .../src/utcp_text/text_communication_protocol.py | 12 +++++------- 9 files changed, 44 insertions(+), 59 deletions(-) diff --git a/core/src/utcp/__init__.py b/core/src/utcp/__init__.py index cfe0dd4..cf7c806 100644 --- a/core/src/utcp/__init__.py +++ b/core/src/utcp/__init__.py @@ -1,10 +1,7 @@ import logging import sys -logger = logging.getLogger("utcp") - -if not logger.hasHandlers(): # Only add default handler if user didn't configure logging - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) - logger.addHandler(handler) - logger.setLevel(logging.INFO) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) diff --git a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py index f1a6fed..2fd8b99 100644 --- a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py +++ b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py @@ -32,14 +32,12 @@ from utcp_cli.cli_call_template import CliCallTemplate, CliCallTemplateSerializer import logging -logger = logging.getLogger(__name__) - -if not logger.hasHandlers(): # Only add default handler if user didn't configure logging - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) - logger.addHandler(handler) - logger.setLevel(logging.INFO) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) +logger = logging.getLogger(__name__) class CliCommunicationProtocol(CommunicationProtocol): """REQUIRED diff --git a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py index d558e82..f27f803 100644 --- a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py +++ b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py @@ -11,14 +11,12 @@ from utcp.shared.auth import ApiKeyAuth, BasicAuth, OAuth2Auth import logging -logger = logging.getLogger(__name__) - -if not logger.hasHandlers(): # Only add default handler if user didn't configure logging - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) - logger.addHandler(handler) - logger.setLevel(logging.INFO) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) +logger = logging.getLogger(__name__) class GraphQLClientTransport(ClientTransportInterface): """ diff --git a/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py index 15e2c23..a62e4d3 100644 --- a/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py @@ -35,13 +35,12 @@ from utcp_http.openapi_converter import OpenApiConverter import logging -logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) -if not logger.hasHandlers(): # Only add default handler if user didn't configure logging - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) - logger.addHandler(handler) - logger.setLevel(logging.INFO) +logger = logging.getLogger(__name__) class HttpCommunicationProtocol(CommunicationProtocol): """REQUIRED diff --git a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py index a5aac67..b741664 100644 --- a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py @@ -20,13 +20,12 @@ import traceback import logging -logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) -if not logger.hasHandlers(): # Only add default handler if user didn't configure logging - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) - logger.addHandler(handler) - logger.setLevel(logging.INFO) +logger = logging.getLogger(__name__) class SseCommunicationProtocol(CommunicationProtocol): """REQUIRED diff --git a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py index 2ba868e..5639cc5 100644 --- a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py @@ -17,13 +17,12 @@ from aiohttp import ClientSession, BasicAuth as AiohttpBasicAuth, ClientResponse import logging -logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) -if not logger.hasHandlers(): # Only add default handler if user didn't configure logging - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) - logger.addHandler(handler) - logger.setLevel(logging.INFO) +logger = logging.getLogger(__name__) class StreamableHttpCommunicationProtocol(CommunicationProtocol): """REQUIRED diff --git a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py index de2e8c4..3d419d4 100644 --- a/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py +++ b/plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py @@ -16,14 +16,12 @@ from utcp.utcp_client import UtcpClient import logging -logger = logging.getLogger(__name__) - -if not logger.hasHandlers(): # Only add default handler if user didn't configure logging - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) - logger.addHandler(handler) - logger.setLevel(logging.INFO) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) +logger = logging.getLogger(__name__) class McpCommunicationProtocol(CommunicationProtocol): """REQUIRED diff --git a/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py b/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py index cab2665..1b360a8 100644 --- a/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py +++ b/plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py @@ -15,13 +15,12 @@ from utcp.shared.tool import Tool import logging -logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) -if not logger.hasHandlers(): # Only add default handler if user didn't configure logging - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) - logger.addHandler(handler) - logger.setLevel(logging.INFO) +logger = logging.getLogger(__name__) class TCPTransport(ClientTransportInterface): """Transport implementation for TCP-based tool providers. diff --git a/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py b/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py index e35373b..0d672dd 100644 --- a/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py +++ b/plugins/communication_protocols/text/src/utcp_text/text_communication_protocol.py @@ -24,14 +24,12 @@ import logging -logger = logging.getLogger(__name__) - -if not logger.hasHandlers(): # Only add default handler if user didn't configure logging - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s")) - logger.addHandler(handler) - logger.setLevel(logging.INFO) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s" +) +logger = logging.getLogger(__name__) class TextCommunicationProtocol(CommunicationProtocol): """REQUIRED From 666eb1ec6c9782b621d74e71a9c4cc8eeb4bc341 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Sun, 7 Sep 2025 11:31:58 +0200 Subject: [PATCH 21/21] Update minor versions of all projects --- README.md | 4 ++-- core/README.md | 4 ++-- core/pyproject.toml | 2 +- core/src/utcp/python_specific_tooling/version.py | 2 +- plugins/communication_protocols/cli/pyproject.toml | 2 +- plugins/communication_protocols/gql/pyproject.toml | 2 +- plugins/communication_protocols/http/pyproject.toml | 2 +- plugins/communication_protocols/mcp/pyproject.toml | 2 +- plugins/communication_protocols/socket/pyproject.toml | 2 +- plugins/communication_protocols/text/README.md | 2 +- plugins/communication_protocols/text/pyproject.toml | 2 +- 11 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1adedc7..9b777e8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Universal Tool Calling Protocol (UTCP) 1.0.1 +# Universal Tool Calling Protocol (UTCP) [![Follow Org](https://img.shields.io/github/followers/universal-tool-calling-protocol?label=Follow%20Org&logo=github)](https://github.com/universal-tool-calling-protocol) [![PyPI Downloads](https://static.pepy.tech/badge/utcp)](https://pepy.tech/projects/utcp) @@ -298,7 +298,7 @@ app = FastAPI() def utcp_discovery(): return { "manual_version": "1.0.0", - "utcp_version": "1.0.1", + "utcp_version": "1.0.2", "tools": [ { "name": "get_weather", diff --git a/core/README.md b/core/README.md index 1adedc7..9b777e8 100644 --- a/core/README.md +++ b/core/README.md @@ -1,4 +1,4 @@ -# Universal Tool Calling Protocol (UTCP) 1.0.1 +# Universal Tool Calling Protocol (UTCP) [![Follow Org](https://img.shields.io/github/followers/universal-tool-calling-protocol?label=Follow%20Org&logo=github)](https://github.com/universal-tool-calling-protocol) [![PyPI Downloads](https://static.pepy.tech/badge/utcp)](https://pepy.tech/projects/utcp) @@ -298,7 +298,7 @@ app = FastAPI() def utcp_discovery(): return { "manual_version": "1.0.0", - "utcp_version": "1.0.1", + "utcp_version": "1.0.2", "tools": [ { "name": "get_weather", diff --git a/core/pyproject.toml b/core/pyproject.toml index aa17d4d..085595d 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp" -version = "1.0.1" +version = "1.0.2" authors = [ { name = "UTCP Contributors" }, ] diff --git a/core/src/utcp/python_specific_tooling/version.py b/core/src/utcp/python_specific_tooling/version.py index 2ebf326..234ca20 100644 --- a/core/src/utcp/python_specific_tooling/version.py +++ b/core/src/utcp/python_specific_tooling/version.py @@ -5,7 +5,7 @@ logger = logging.getLogger(__name__) -__version__ = "1.0.1" +__version__ = "1.0.2" try: __version__ = version("utcp") except PackageNotFoundError: diff --git a/plugins/communication_protocols/cli/pyproject.toml b/plugins/communication_protocols/cli/pyproject.toml index ff37fd5..c718478 100644 --- a/plugins/communication_protocols/cli/pyproject.toml +++ b/plugins/communication_protocols/cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-cli" -version = "1.0.1" +version = "1.0.2" authors = [ { name = "UTCP Contributors" }, ] diff --git a/plugins/communication_protocols/gql/pyproject.toml b/plugins/communication_protocols/gql/pyproject.toml index 1baa897..7c752c3 100644 --- a/plugins/communication_protocols/gql/pyproject.toml +++ b/plugins/communication_protocols/gql/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-gql" -version = "1.0.1" +version = "1.0.2" authors = [ { name = "UTCP Contributors" }, ] diff --git a/plugins/communication_protocols/http/pyproject.toml b/plugins/communication_protocols/http/pyproject.toml index cbd0ed8..52104c4 100644 --- a/plugins/communication_protocols/http/pyproject.toml +++ b/plugins/communication_protocols/http/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-http" -version = "1.0.3" +version = "1.0.4" authors = [ { name = "UTCP Contributors" }, ] diff --git a/plugins/communication_protocols/mcp/pyproject.toml b/plugins/communication_protocols/mcp/pyproject.toml index 8848c8e..36bb48e 100644 --- a/plugins/communication_protocols/mcp/pyproject.toml +++ b/plugins/communication_protocols/mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-mcp" -version = "1.0.1" +version = "1.0.2" authors = [ { name = "UTCP Contributors" }, ] diff --git a/plugins/communication_protocols/socket/pyproject.toml b/plugins/communication_protocols/socket/pyproject.toml index ac8e507..06f845e 100644 --- a/plugins/communication_protocols/socket/pyproject.toml +++ b/plugins/communication_protocols/socket/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-socket" -version = "1.0.1" +version = "1.0.2" authors = [ { name = "UTCP Contributors" }, ] diff --git a/plugins/communication_protocols/text/README.md b/plugins/communication_protocols/text/README.md index 53a7fd3..2057bf8 100644 --- a/plugins/communication_protocols/text/README.md +++ b/plugins/communication_protocols/text/README.md @@ -52,7 +52,7 @@ Next, define a UTCP manual that describes your tool. The `tool_call_template` mu ```json { "manual_version": "1.0.0", - "utcp_version": "1.0.1", + "utcp_version": "1.0.2", "tools": [ { "name": "get_mock_user", diff --git a/plugins/communication_protocols/text/pyproject.toml b/plugins/communication_protocols/text/pyproject.toml index 181d0e5..3780c57 100644 --- a/plugins/communication_protocols/text/pyproject.toml +++ b/plugins/communication_protocols/text/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-text" -version = "1.0.1" +version = "1.0.2" authors = [ { name = "UTCP Contributors" }, ]