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/core/src/utcp/implementations/utcp_client_implementation.py b/core/src/utcp/implementations/utcp_client_implementation.py index 555ba2e..01c13a9 100644 --- a/core/src/utcp/implementations/utcp_client_implementation.py +++ b/core/src/utcp/implementations/utcp_client_implementation.py @@ -110,7 +110,7 @@ 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: for tool in result.manual.tools: if not tool.name.startswith(manual_call_template.name + "."): 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..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 @@ -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.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) + 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..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 @@ -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.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) + + 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..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 @@ -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.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) + 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..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 @@ -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.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) + 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..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 @@ -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.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) + 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..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 @@ -1,4 +1,5 @@ -from typing import Any, Dict, Optional, AsyncGenerator, TYPE_CHECKING +import sys +from typing import Any, Dict, Optional, AsyncGenerator, TYPE_CHECKING, Tuple import json from mcp_use import MCPClient @@ -17,6 +18,13 @@ 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) + + 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 warning 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,21 @@ 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") + + 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.""" @@ -86,7 +114,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 +126,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 +142,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,47 +162,13 @@ 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) 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) @@ -192,9 +186,11 @@ 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}'") + 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 utcp_tool = Tool( @@ -208,15 +204,15 @@ 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 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", @@ -236,12 +232,13 @@ 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, manual=UtcpManual( @@ -260,37 +257,126 @@ 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: - 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_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: - logger.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 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 + + 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 @@ -298,21 +384,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 +462,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 +491,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 +504,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 +520,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/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) 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..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 @@ -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.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) + 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..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 @@ -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.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) + + class TextCommunicationProtocol(CommunicationProtocol): """REQUIRED Communication protocol for file-based UTCP manuals and tools."""