From 15f52c993af24fa8301d5fa2f44edb5c60fa5a5f Mon Sep 17 00:00:00 2001 From: Luca Perrozzi Date: Mon, 15 Sep 2025 21:14:07 +0000 Subject: [PATCH 1/6] Add auth_tools field for selective authentication in OpenAPI tool generation - Add auth_tools field to HttpCallTemplate for tool-specific authentication - Implement compatibility checking between OpenAPI security schemes and auth_tools - Apply real credentials when compatible, use placeholders when incompatible - Preserve existing behavior for public endpoints (no auth required) - Add comprehensive test coverage for all authentication scenarios - Update documentation with auth_tools examples and usage - Maintain full backward compatibility --- .../http/tests/test_auth_tools.py | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 plugins/communication_protocols/http/tests/test_auth_tools.py diff --git a/plugins/communication_protocols/http/tests/test_auth_tools.py b/plugins/communication_protocols/http/tests/test_auth_tools.py new file mode 100644 index 0000000..f806930 --- /dev/null +++ b/plugins/communication_protocols/http/tests/test_auth_tools.py @@ -0,0 +1,250 @@ +""" +Tests for auth_tools functionality in OpenAPI converter. + +Tests the new auth_tools feature that allows manual call templates to provide +authentication configuration for generated tools, with compatibility checking +against OpenAPI security schemes. +""" + +import pytest +from utcp_http.openapi_converter import OpenApiConverter +from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth +from utcp.data.auth_implementations.basic_auth import BasicAuth + + +def test_compatible_api_key_auth(): + """Test auth_tools with compatible API key authentication.""" + openapi_spec = { + "swagger": "2.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "host": "api.test.com", + "securityDefinitions": { + "api_key": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + }, + "paths": { + "/protected": { + "get": { + "operationId": "getProtected", + "security": [{"api_key": []}], + "responses": {"200": {"description": "success"}} + } + } + } + } + + # Compatible auth_tools (same header name and location) + auth_tools = ApiKeyAuth( + api_key="Bearer token-123", + var_name="Authorization", + location="header" + ) + + converter = OpenApiConverter(openapi_spec, auth_tools=auth_tools) + manual = converter.convert() + + assert len(manual.tools) == 1 + tool = manual.tools[0] + + # Should use auth_tools values since they're compatible + assert tool.tool_call_template.auth is not None + assert isinstance(tool.tool_call_template.auth, ApiKeyAuth) + assert tool.tool_call_template.auth.api_key == "Bearer token-123" + assert tool.tool_call_template.auth.var_name == "Authorization" + assert tool.tool_call_template.auth.location == "header" + + +def test_incompatible_api_key_auth(): + """Test auth_tools with incompatible API key authentication.""" + openapi_spec = { + "swagger": "2.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "host": "api.test.com", + "securityDefinitions": { + "custom_key": { + "type": "apiKey", + "name": "X-API-Key", # Different header name + "in": "header" + } + }, + "paths": { + "/protected": { + "get": { + "operationId": "getProtected", + "security": [{"custom_key": []}], + "responses": {"200": {"description": "success"}} + } + } + } + } + + # Incompatible auth_tools (different header name) + auth_tools = ApiKeyAuth( + api_key="Bearer token-123", + var_name="Authorization", # Different from OpenAPI + location="header" + ) + + converter = OpenApiConverter(openapi_spec, auth_tools=auth_tools) + manual = converter.convert() + + assert len(manual.tools) == 1 + tool = manual.tools[0] + + # Should use OpenAPI scheme with placeholder since incompatible + assert tool.tool_call_template.auth is not None + assert isinstance(tool.tool_call_template.auth, ApiKeyAuth) + assert tool.tool_call_template.auth.api_key.startswith("${") # Placeholder + assert tool.tool_call_template.auth.var_name == "X-API-Key" # From OpenAPI + assert tool.tool_call_template.auth.location == "header" + + +def test_case_insensitive_header_matching(): + """Test that header name matching is case-insensitive.""" + openapi_spec = { + "swagger": "2.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "host": "api.test.com", + "securityDefinitions": { + "api_key": { + "type": "apiKey", + "name": "authorization", # lowercase + "in": "header" + } + }, + "paths": { + "/protected": { + "get": { + "operationId": "getProtected", + "security": [{"api_key": []}], + "responses": {"200": {"description": "success"}} + } + } + } + } + + # auth_tools with different case + auth_tools = ApiKeyAuth( + api_key="Bearer token-123", + var_name="Authorization", # uppercase + location="header" + ) + + converter = OpenApiConverter(openapi_spec, auth_tools=auth_tools) + manual = converter.convert() + + tool = manual.tools[0] + + # Should be compatible despite case difference + assert tool.tool_call_template.auth.api_key == "Bearer token-123" + + +def test_different_auth_types_incompatible(): + """Test that different auth types are incompatible.""" + openapi_spec = { + "swagger": "2.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "host": "api.test.com", + "securityDefinitions": { + "basic_auth": { + "type": "basic" + } + }, + "paths": { + "/protected": { + "get": { + "operationId": "getProtected", + "security": [{"basic_auth": []}], + "responses": {"200": {"description": "success"}} + } + } + } + } + + # Different auth type (API key vs Basic) + auth_tools = ApiKeyAuth( + api_key="Bearer token-123", + var_name="Authorization", + location="header" + ) + + converter = OpenApiConverter(openapi_spec, auth_tools=auth_tools) + manual = converter.convert() + + tool = manual.tools[0] + + # Should use OpenAPI scheme since types don't match + assert isinstance(tool.tool_call_template.auth, BasicAuth) + assert tool.tool_call_template.auth.username.startswith("${") # Placeholder + + +def test_public_endpoint_no_auth(): + """Test that public endpoints remain public regardless of auth_tools.""" + openapi_spec = { + "swagger": "2.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "host": "api.test.com", + "paths": { + "/public": { + "get": { + "operationId": "getPublic", + # No security field - public endpoint + "responses": {"200": {"description": "success"}} + } + } + } + } + + auth_tools = ApiKeyAuth( + api_key="Bearer token-123", + var_name="Authorization", + location="header" + ) + + converter = OpenApiConverter(openapi_spec, auth_tools=auth_tools) + manual = converter.convert() + + tool = manual.tools[0] + + # Should have no auth since endpoint is public + assert tool.tool_call_template.auth is None + + +def test_no_auth_tools_uses_openapi_scheme(): + """Test fallback to OpenAPI scheme when no auth_tools provided.""" + openapi_spec = { + "swagger": "2.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "host": "api.test.com", + "securityDefinitions": { + "api_key": { + "type": "apiKey", + "name": "X-API-Key", + "in": "header" + } + }, + "paths": { + "/protected": { + "get": { + "operationId": "getProtected", + "security": [{"api_key": []}], + "responses": {"200": {"description": "success"}} + } + } + } + } + + # No auth_tools provided + converter = OpenApiConverter(openapi_spec, auth_tools=None) + manual = converter.convert() + + tool = manual.tools[0] + + # Should use OpenAPI scheme with placeholder + assert tool.tool_call_template.auth is not None + assert isinstance(tool.tool_call_template.auth, ApiKeyAuth) + assert tool.tool_call_template.auth.api_key.startswith("${") + assert tool.tool_call_template.auth.var_name == "X-API-Key" From 11a88f132f3ba146e889c00f624a4c19914f4515 Mon Sep 17 00:00:00 2001 From: Luca Perrozzi Date: Mon, 15 Sep 2025 21:15:40 +0000 Subject: [PATCH 2/6] Update implementation files and documentation for auth_tools feature - Update HttpCallTemplate, HttpCommunicationProtocol, and OpenApiConverter - Add auth_tools examples to README.md - Update existing tests for new auth_tools parameter - Add integration test for auth_tools field functionality --- README.md | 17 +++++- .../http/src/utcp_http/http_call_template.py | 10 +++- .../utcp_http/http_communication_protocol.py | 2 +- .../http/src/utcp_http/openapi_converter.py | 54 +++++++++++++++++-- .../tests/test_http_communication_protocol.py | 32 +++++++++++ .../http/tests/test_openapi_converter.py | 32 +++++++++-- 6 files changed, 134 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4a58c6e..0287f95 100644 --- a/README.md +++ b/README.md @@ -376,12 +376,18 @@ Configuration examples for each protocol. Remember to replace `provider_type` wi "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": { // Optional, authentication for accessing the OpenAPI spec URL (example using ApiKeyAuth for Bearer token) "auth_type": "api_key", "api_key": "Bearer $API_KEY", // Required "var_name": "Authorization", // Optional, default: "X-Api-Key" "location": "header" // Optional, default: "header" }, + "auth_tools": { // Optional, authentication for generated tools (applied only to endpoints requiring auth per OpenAPI spec) + "auth_type": "api_key", + "api_key": "Bearer $TOOL_API_KEY", // Required + "var_name": "Authorization", // Optional, default: "X-Api-Key" + "location": "header" // Optional, default: "header" + }, "headers": { // Optional "X-Custom-Header": "value" }, @@ -569,7 +575,13 @@ client = await UtcpClient.create(config={ "manual_call_templates": [{ "name": "github", "call_template_type": "http", - "url": "https://api.github.com/openapi.json" + "url": "https://api.github.com/openapi.json", + "auth_tools": { # Authentication for generated tools requiring auth + "auth_type": "api_key", + "api_key": "Bearer ${GITHUB_TOKEN}", + "var_name": "Authorization", + "location": "header" + } }] }) ``` @@ -579,6 +591,7 @@ client = await UtcpClient.create(config={ - ✅ **Zero Infrastructure**: No servers to deploy or maintain - ✅ **Direct API Calls**: Native performance, no proxy overhead - ✅ **Automatic Conversion**: OpenAPI schemas → UTCP tools +- ✅ **Selective Authentication**: Only protected endpoints get auth, public endpoints remain accessible - ✅ **Authentication Preserved**: API keys, OAuth2, Basic auth supported - ✅ **Multi-format Support**: JSON, YAML, OpenAPI 2.0/3.0 - ✅ **Batch Processing**: Convert multiple APIs simultaneously 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 b3a9e70..5ccf28c 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 @@ -40,6 +40,12 @@ class HttpCallTemplate(CallTemplate): "var_name": "Authorization", "location": "header" }, + "auth_tools": { + "auth_type": "api_key", + "api_key": "Bearer ${TOOL_API_KEY}", + "var_name": "Authorization", + "location": "header" + }, "headers": { "X-Custom-Header": "value" }, @@ -85,7 +91,8 @@ class HttpCallTemplate(CallTemplate): url: The base URL for the HTTP endpoint. Supports path parameters like "https://api.example.com/users/{user_id}/posts/{post_id}". content_type: The Content-Type header for requests. - auth: Optional authentication configuration. + auth: Optional authentication configuration for accessing the OpenAPI spec URL. + auth_tools: Optional authentication configuration for generated tools. Applied only to endpoints requiring auth per OpenAPI spec. headers: Optional static headers to include in all requests. body_field: Name of the tool argument to map to the HTTP request body. header_fields: List of tool argument names to map to HTTP request headers. @@ -96,6 +103,7 @@ class HttpCallTemplate(CallTemplate): url: str content_type: str = Field(default="application/json") auth: Optional[Auth] = None + auth_tools: Optional[Auth] = Field(default=None, description="Authentication configuration for generated tools (applied only to endpoints requiring auth per OpenAPI spec)") headers: Optional[Dict[str, str]] = None body_field: Optional[str] = Field(default="body", description="The name of the single input field to be sent as the request body.") header_fields: Optional[List[str]] = Field(default=None, description="List of input fields to be sent as request headers.") 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 a62e4d3..191a749 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 @@ -197,7 +197,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R utcp_manual = UtcpManualSerializer().validate_dict(response_data) else: logger.info(f"Assuming OpenAPI spec from '{manual_call_template.name}'. Converting to UTCP manual.") - converter = OpenApiConverter(response_data, spec_url=manual_call_template.url, call_template_name=manual_call_template.name) + converter = OpenApiConverter(response_data, spec_url=manual_call_template.url, call_template_name=manual_call_template.name, auth_tools=manual_call_template.auth_tools) utcp_manual = converter.convert() return RegisterManualResult( 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 be20fe6..c16412a 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -87,7 +87,7 @@ class OpenApiConverter: call_template_name: Normalized name for the call_template derived from the spec. """ - def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, call_template_name: Optional[str] = None): + def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, call_template_name: Optional[str] = None, auth_tools: Optional[Auth] = None): """Initializes the OpenAPI converter. Args: @@ -96,9 +96,12 @@ def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, 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. + auth_tools: Optional auth configuration for generated tools. + Applied only to endpoints that require authentication per OpenAPI spec. """ self.spec = openapi_spec self.spec_url = spec_url + self.auth_tools = auth_tools # Single counter for all placeholder variables self.placeholder_counter = 0 if call_template_name is None: @@ -160,7 +163,10 @@ def convert(self) -> UtcpManual: def _extract_auth(self, operation: Dict[str, Any]) -> Optional[Auth]: """ - Extracts authentication information from OpenAPI operation and global security schemes.""" + Extracts authentication information from OpenAPI operation and global security schemes. + Uses auth_tools configuration when compatible with OpenAPI auth requirements. + Supports both OpenAPI 2.0 and 3.0 security schemes. + """ # First check for operation-level security requirements security_requirements = operation.get("security", []) @@ -168,11 +174,11 @@ def _extract_auth(self, operation: Dict[str, Any]) -> Optional[Auth]: if not security_requirements: security_requirements = self.spec.get("security", []) - # If no security requirements, return None + # If no security requirements, return None (endpoint is public) if not security_requirements: return None - # Get security schemes - support both OpenAPI 2.0 and 3.0 + # Generate auth from OpenAPI security schemes - support both OpenAPI 2.0 and 3.0 security_schemes = self._get_security_schemes() # Process the first security requirement (most common case) @@ -181,9 +187,47 @@ def _extract_auth(self, operation: Dict[str, Any]) -> Optional[Auth]: for scheme_name, scopes in security_req.items(): if scheme_name in security_schemes: scheme = security_schemes[scheme_name] - return self._create_auth_from_scheme(scheme, scheme_name) + openapi_auth = self._create_auth_from_scheme(scheme, scheme_name) + + # If compatible with auth_tools, use actual values from manual call template + if self._is_auth_compatible(openapi_auth, self.auth_tools): + return self.auth_tools + else: + return openapi_auth # Use placeholder from OpenAPI scheme return None + + def _is_auth_compatible(self, openapi_auth: Optional[Auth], auth_tools: Optional[Auth]) -> bool: + """ + Checks if auth_tools configuration is compatible with OpenAPI auth requirements. + + Args: + openapi_auth: Auth generated from OpenAPI security scheme + auth_tools: Auth configuration from manual call template + + Returns: + True if compatible and auth_tools should be used, False otherwise + """ + if not openapi_auth or not auth_tools: + return False + + # Must be same auth type + if type(openapi_auth) != type(auth_tools): + return False + + # For API Key auth, check header name and location compatibility + if hasattr(openapi_auth, 'var_name') and hasattr(auth_tools, 'var_name'): + openapi_var = openapi_auth.var_name.lower() if openapi_auth.var_name else "" + tools_var = auth_tools.var_name.lower() if auth_tools.var_name else "" + + if openapi_var != tools_var: + return False + + if hasattr(openapi_auth, 'location') and hasattr(auth_tools, 'location'): + if openapi_auth.location != auth_tools.location: + return False + + return True def _get_security_schemes(self) -> Dict[str, Any]: """ diff --git a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py index 753ec8e..fee2beb 100644 --- a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py +++ b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py @@ -694,3 +694,35 @@ async def test_call_tool_openlibrary_style_url(http_transport): expected_remaining = {"format": "json"} http_transport._build_url_with_path_params(call_template.url, arguments) assert arguments == expected_remaining + + +def test_auth_tools_integration(): + """Test that auth_tools field is properly integrated in HttpCallTemplate.""" + from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth + from utcp_http.http_call_template import HttpCallTemplateSerializer + + # Create auth_tools configuration + auth_tools = ApiKeyAuth( + api_key="Bearer test-token", + var_name="Authorization", + location="header" + ) + + # Create HttpCallTemplate with auth_tools + call_template = HttpCallTemplate( + name="test-auth-tools", + url="https://api.example.com/spec.json", + auth_tools=auth_tools + ) + + # Verify auth_tools is stored correctly + assert call_template.auth_tools is not None + assert call_template.auth_tools.api_key == "Bearer test-token" + assert call_template.auth_tools.var_name == "Authorization" + assert call_template.auth_tools.location == "header" + + # Verify it can be serialized (auth_type is included for security) + serializer = HttpCallTemplateSerializer() + serialized = serializer.to_dict(call_template) + assert "auth_tools" in serialized + assert serialized["auth_tools"]["auth_type"] == "api_key" diff --git a/plugins/communication_protocols/http/tests/test_openapi_converter.py b/plugins/communication_protocols/http/tests/test_openapi_converter.py index 77382c7..aa3f3cb 100644 --- a/plugins/communication_protocols/http/tests/test_openapi_converter.py +++ b/plugins/communication_protocols/http/tests/test_openapi_converter.py @@ -3,6 +3,7 @@ import sys from utcp_http.openapi_converter import OpenApiConverter from utcp.data.utcp_manual import UtcpManual +from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth @pytest.mark.asyncio @@ -28,7 +29,30 @@ async def test_openai_spec_conversion(): assert sample_tool.tool_call_template.http_method == "POST" body_schema = sample_tool.inputs.properties.get('body') assert body_schema is not None - assert body_schema.properties is not None - assert "messages" in body_schema.properties - assert "model" in body_schema.properties - assert "choices" in sample_tool.outputs.properties + + +@pytest.mark.asyncio +async def test_openapi_converter_with_auth_tools(): + """Test OpenAPI converter with auth_tools parameter.""" + url = "https://api.apis.guru/v2/specs/openai.com/1.2.0/openapi.json" + + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + response.raise_for_status() + openapi_spec = await response.json() + + # Test with auth_tools parameter + auth_tools = ApiKeyAuth( + api_key="Bearer test-token", + var_name="Authorization", + location="header" + ) + + converter = OpenApiConverter(openapi_spec, spec_url=url, auth_tools=auth_tools) + utcp_manual = converter.convert() + + assert isinstance(utcp_manual, UtcpManual) + assert len(utcp_manual.tools) > 0 + + # Verify auth_tools is stored + assert converter.auth_tools == auth_tools From d0cece7847dee326769404b7f702f8cf7b282214 Mon Sep 17 00:00:00 2001 From: Luca Perrozzi Date: Mon, 15 Sep 2025 21:39:12 +0000 Subject: [PATCH 3/6] fix: resolve pytest fixture dependency issue in HTTP tests - Fix aiohttp_client fixture usage by properly injecting app dependency - Ensure all test fixtures receive required parameters correctly - All 153 tests now pass without fixture conflicts --- .../tests/test_http_communication_protocol.py | 86 +++++++++++-------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py index fee2beb..518b8df 100644 --- a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py +++ b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py @@ -142,11 +142,6 @@ async def error_handler(request): return app -@pytest_asyncio.fixture -async def aiohttp_client(aiohttp_client, app): - """Create a test client for our app.""" - return await aiohttp_client(app) - @pytest_asyncio.fixture async def http_transport(): @@ -155,48 +150,52 @@ async def http_transport(): @pytest_asyncio.fixture -async def http_call_template(aiohttp_client): +async def http_call_template(aiohttp_client, app): """Create a basic HTTP call template for testing.""" + client = await aiohttp_client(app) return HttpCallTemplate( name="test_call_template", - url=f"http://localhost:{aiohttp_client.port}/tools", + url=f"http://localhost:{client.port}/tools", http_method="GET" ) @pytest_asyncio.fixture -async def api_key_call_template(aiohttp_client): +async def api_key_call_template(aiohttp_client, app): """Create an HTTP call template with API key auth.""" + client = await aiohttp_client(app) return HttpCallTemplate( name="api-key-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="GET", auth=ApiKeyAuth(api_key="test-api-key", var_name="X-API-Key", location="header") ) @pytest_asyncio.fixture -async def basic_auth_call_template(aiohttp_client): +async def basic_auth_call_template(aiohttp_client, app): """Create an HTTP call template with Basic auth.""" + client = await aiohttp_client(app) return HttpCallTemplate( name="basic-auth-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="GET", auth=BasicAuth(username="user", password="pass") ) @pytest_asyncio.fixture -async def oauth2_call_template(aiohttp_client): +async def oauth2_call_template(aiohttp_client, app): """Create an HTTP call template with OAuth2 auth.""" + client = await aiohttp_client(app) return HttpCallTemplate( name="oauth2-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="GET", auth=OAuth2Auth( client_id="client-id", client_secret="client-secret", - token_url=f"http://localhost:{aiohttp_client.port}/token", + token_url=f"http://localhost:{client.port}/token", scope="read write" ) ) @@ -232,12 +231,13 @@ async def test_register_manual(http_transport: HttpCommunicationProtocol, http_c # Test error handling when registering a manual @pytest.mark.asyncio -async def test_register_manual_http_error(http_transport, aiohttp_client): +async def test_register_manual_http_error(http_transport, aiohttp_client, app): """Test error handling when registering a manual.""" # Create a call template that points to our error endpoint + client = await aiohttp_client(app) error_call_template = HttpCallTemplate( name="error-call-template", - url=f"http://localhost:{aiohttp_client.port}/error", + url=f"http://localhost:{client.port}/error", http_method="GET" ) @@ -263,12 +263,13 @@ async def test_deregister_manual(http_transport, http_call_template): # Test call_tool_basic @pytest.mark.asyncio -async def test_call_tool_basic(http_transport, http_call_template, aiohttp_client): +async def test_call_tool_basic(http_transport, http_call_template, aiohttp_client, app): """Test calling a tool with basic configuration.""" # Update call template URL to point to our /tool endpoint + client = await aiohttp_client(app) tool_call_template = HttpCallTemplate( name=http_call_template.name, - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="GET" ) @@ -314,17 +315,18 @@ async def test_call_tool_with_oauth2(http_transport, oauth2_call_template): @pytest.mark.asyncio -async def test_call_tool_with_oauth2_header_auth(http_transport, aiohttp_client): +async def test_call_tool_with_oauth2_header_auth(http_transport, aiohttp_client, app): """Test calling a tool with OAuth2 authentication (credentials in header).""" # This call template points to an endpoint that expects Basic Auth for the token + client = await aiohttp_client(app) oauth2_header_call_template = HttpCallTemplate( name="oauth2-header-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="GET", auth=OAuth2Auth( client_id="client-id", client_secret="client-secret", - token_url=f"http://localhost:{aiohttp_client.port}/token_header_auth", + token_url=f"http://localhost:{client.port}/token_header_auth", scope="read write" ) ) @@ -339,12 +341,13 @@ async def test_call_tool_with_oauth2_header_auth(http_transport, aiohttp_client) # Test call_tool_with_body_field @pytest.mark.asyncio -async def test_call_tool_with_body_field(http_transport, aiohttp_client): +async def test_call_tool_with_body_field(http_transport, aiohttp_client, app): """Test calling a tool with a body field.""" # Create call template with body field + client = await aiohttp_client(app) call_template = HttpCallTemplate( name="body-field-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="POST", body_field="data" ) @@ -363,12 +366,13 @@ async def test_call_tool_with_body_field(http_transport, aiohttp_client): # Test call_tool_with_path_params @pytest.mark.asyncio -async def test_call_tool_with_path_params(http_transport, aiohttp_client): +async def test_call_tool_with_path_params(http_transport, aiohttp_client, app): """Test calling a tool with path parameters.""" # Create call template with path params in URL + client = await aiohttp_client(app) call_template = HttpCallTemplate( name="path-params-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool/{{param1}}", + url=f"http://localhost:{client.port}/tool/{{param1}}", http_method="GET" ) @@ -386,12 +390,13 @@ async def test_call_tool_with_path_params(http_transport, aiohttp_client): # Test call_tool_with_custom_headers @pytest.mark.asyncio -async def test_call_tool_with_custom_headers(http_transport, aiohttp_client): +async def test_call_tool_with_custom_headers(http_transport, aiohttp_client, app): """Test calling a tool with custom headers.""" # Create call template with custom headers + client = await aiohttp_client(app) call_template = HttpCallTemplate( name="custom-headers-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="GET", additional_headers={"X-Custom-Header": "custom-value"} ) @@ -527,11 +532,12 @@ async def path_param_handler(request): @pytest.mark.asyncio -async def test_call_tool_streaming_basic(http_transport, http_call_template, aiohttp_client): +async def test_call_tool_streaming_basic(http_transport, http_call_template, aiohttp_client, app): """Streaming basic call should yield one result identical to call_tool.""" + client = await aiohttp_client(app) tool_call_template = HttpCallTemplate( name=http_call_template.name, - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="GET", ) stream = http_transport.call_tool_streaming(None, "test_tool", {"param1": "value1"}, tool_call_template) @@ -564,16 +570,17 @@ async def test_call_tool_streaming_with_oauth2(http_transport, oauth2_call_templ @pytest.mark.asyncio -async def test_call_tool_streaming_with_oauth2_header_auth(http_transport, aiohttp_client): +async def test_call_tool_streaming_with_oauth2_header_auth(http_transport, aiohttp_client, app): """Streaming with OAuth2 (credentials in header) yields one aggregated result.""" + client = await aiohttp_client(app) oauth2_header_call_template = HttpCallTemplate( name="oauth2-header-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="GET", auth=OAuth2Auth( client_id="client-id", client_secret="client-secret", - token_url=f"http://localhost:{aiohttp_client.port}/token_header_auth", + token_url=f"http://localhost:{client.port}/token_header_auth", scope="read write", ), ) @@ -583,11 +590,12 @@ async def test_call_tool_streaming_with_oauth2_header_auth(http_transport, aioht @pytest.mark.asyncio -async def test_call_tool_streaming_with_body_field(http_transport, aiohttp_client): +async def test_call_tool_streaming_with_body_field(http_transport, aiohttp_client, app): """Streaming POST with body_field yields one aggregated result.""" + client = await aiohttp_client(app) call_template = HttpCallTemplate( name="body-field-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="POST", body_field="data", ) @@ -602,11 +610,12 @@ async def test_call_tool_streaming_with_body_field(http_transport, aiohttp_clien @pytest.mark.asyncio -async def test_call_tool_streaming_with_path_params(http_transport, aiohttp_client): +async def test_call_tool_streaming_with_path_params(http_transport, aiohttp_client, app): """Streaming with URL path params yields one aggregated result.""" + client = await aiohttp_client(app) call_template = HttpCallTemplate( name="path-params-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool/{{param1}}", + url=f"http://localhost:{client.port}/tool/{{param1}}", http_method="GET", ) stream = http_transport.call_tool_streaming( @@ -620,11 +629,12 @@ async def test_call_tool_streaming_with_path_params(http_transport, aiohttp_clie @pytest.mark.asyncio -async def test_call_tool_streaming_with_custom_headers(http_transport, aiohttp_client): +async def test_call_tool_streaming_with_custom_headers(http_transport, aiohttp_client, app): """Streaming with additional headers yields one aggregated result.""" + client = await aiohttp_client(app) call_template = HttpCallTemplate( name="custom-headers-call-template", - url=f"http://localhost:{aiohttp_client.port}/tool", + url=f"http://localhost:{client.port}/tool", http_method="GET", additional_headers={"X-Custom-Header": "custom-value"}, ) From 1589bdff5e7602c1438184e40689babd48bd7592 Mon Sep 17 00:00:00 2001 From: Luca Perrozzi Date: Mon, 15 Sep 2025 21:56:44 +0000 Subject: [PATCH 4/6] feat: add auth_tools support to text plugin - Add auth_tools field to TextCallTemplate for OpenAPI-generated tools - Pass auth_tools to OpenApiConverter when processing local OpenAPI specs - Update documentation to reflect new authentication capabilities - Add test coverage for auth_tools functionality - Maintains backward compatibility (auth_tools is optional) This allows text plugin to apply authentication to tools generated from local OpenAPI specifications, enabling secure API calls while keeping file access authentication-free. --- README.md | 8 +++++++- plugins/communication_protocols/text/README.md | 4 +++- .../text/src/utcp_text/text_call_template.py | 7 +++++-- .../src/utcp_text/text_communication_protocol.py | 7 ++++++- .../tests/test_text_communication_protocol.py | 16 ++++++++++++++++ 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0287f95..9552dfa 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,13 @@ Note the name change from `http_stream` to `streamable_http`. "name": "my_text_manual", "call_template_type": "text", // Required "file_path": "./manuals/my_manual.json", // Required - "auth": null // Optional (always null for Text) + "auth": null, // Optional (always null for Text) + "auth_tools": { // Optional, authentication for generated tools from OpenAPI specs + "auth_type": "api_key", + "api_key": "Bearer ${API_TOKEN}", + "var_name": "Authorization", + "location": "header" + } } ``` diff --git a/plugins/communication_protocols/text/README.md b/plugins/communication_protocols/text/README.md index 2057bf8..27f8525 100644 --- a/plugins/communication_protocols/text/README.md +++ b/plugins/communication_protocols/text/README.md @@ -8,9 +8,11 @@ A simple, file-based resource plugin for UTCP. This plugin allows you to define - **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. +- **OpenAPI Support**: Automatically converts local OpenAPI specs to UTCP tools with optional authentication. - **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. +- **No File Authentication**: Designed for simple, local file access without authentication for file reading. +- **Tool Authentication**: Supports authentication for generated tools from OpenAPI specs via `auth_tools`. ## Installation diff --git a/plugins/communication_protocols/text/src/utcp_text/text_call_template.py b/plugins/communication_protocols/text/src/utcp_text/text_call_template.py index 23ba009..f013e4d 100644 --- a/plugins/communication_protocols/text/src/utcp_text/text_call_template.py +++ b/plugins/communication_protocols/text/src/utcp_text/text_call_template.py @@ -1,7 +1,8 @@ -from typing import Literal +from typing import Literal, Optional from pydantic import Field from utcp.data.call_template import CallTemplate +from utcp.data.auth import Auth from utcp.interfaces.serializer import Serializer from utcp.exceptions import UtcpSerializerValidationError import traceback @@ -16,12 +17,14 @@ class TextCallTemplate(CallTemplate): Attributes: call_template_type: Always "text" for text file call templates. file_path: Path to the file containing the UTCP manual or tool definitions. - auth: Always None - text call templates don't support authentication. + auth: Always None - text call templates don't support authentication for file access. + auth_tools: Optional authentication to apply to generated tools from OpenAPI specs. """ call_template_type: Literal["text"] = "text" file_path: str = Field(..., description="The path to the file containing the UTCP manual or tool definitions.") auth: None = None + auth_tools: Optional[Auth] = Field(None, description="Authentication to apply to generated tools from OpenAPI specs.") class TextCallTemplateSerializer(Serializer[TextCallTemplate]): 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 0d672dd..cdd49ae 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 @@ -70,7 +70,12 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call utcp_manual: UtcpManual if isinstance(data, dict) and ("openapi" in data or "swagger" in data or "paths" in data): self._log_info("Detected OpenAPI specification. Converting to UTCP manual.") - converter = OpenApiConverter(data, spec_url=file_path.as_uri(), call_template_name=manual_call_template.name) + converter = OpenApiConverter( + data, + spec_url=file_path.as_uri(), + call_template_name=manual_call_template.name, + auth_tools=manual_call_template.auth_tools + ) utcp_manual = converter.convert() else: # Try to validate as UTCP manual directly diff --git a/plugins/communication_protocols/text/tests/test_text_communication_protocol.py b/plugins/communication_protocols/text/tests/test_text_communication_protocol.py index 0b7dffb..1c8376f 100644 --- a/plugins/communication_protocols/text/tests/test_text_communication_protocol.py +++ b/plugins/communication_protocols/text/tests/test_text_communication_protocol.py @@ -12,6 +12,7 @@ from utcp_text.text_call_template import TextCallTemplate from utcp.data.call_template import CallTemplate from utcp.data.register_manual_response import RegisterManualResult +from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth from utcp.utcp_client import UtcpClient @pytest_asyncio.fixture @@ -356,3 +357,18 @@ async def test_call_tool_streaming(text_protocol: TextCommunicationProtocol, sam assert chunks == [content] finally: Path(temp_file).unlink() + + +@pytest.mark.asyncio +async def test_text_call_template_with_auth_tools(): + """Test that TextCallTemplate can be created with auth_tools.""" + auth_tools = ApiKeyAuth(api_key="test-key", var_name="Authorization", location="header") + + template = TextCallTemplate( + name="test-template", + file_path="test.json", + auth_tools=auth_tools + ) + + assert template.auth_tools == auth_tools + assert template.auth is None # auth should still be None for file access From 42d940b0e401898e07212b3b8cee05c24b983e47 Mon Sep 17 00:00:00 2001 From: Luca Perrozzi Date: Mon, 15 Sep 2025 22:27:23 +0000 Subject: [PATCH 5/6] fix: add proper serialization/validation for auth and auth_tools fields - Add field_serializer and field_validator for auth_tools in TextCallTemplate - Add field_serializer and field_validator for both auth and auth_tools in HttpCallTemplate - Use AuthSerializer.validate_dict() for proper dict-to-Auth conversion - Add comprehensive test coverage for auth_tools serialization - Ensures dict configurations preserve all critical authentication fields - All 155 tests pass with proper field validation --- .../http/src/utcp_http/http_call_template.py | 44 +++++++++++++++++-- .../text/src/utcp_text/text_call_template.py | 25 +++++++++-- .../tests/test_text_communication_protocol.py | 27 ++++++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) 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 5ccf28c..9b8825e 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 @@ -1,10 +1,10 @@ from utcp.data.call_template import CallTemplate, CallTemplateSerializer -from utcp.data.auth import Auth +from utcp.data.auth import Auth, AuthSerializer from utcp.interfaces.serializer import Serializer from utcp.exceptions import UtcpSerializerValidationError import traceback -from typing import Optional, Dict, List, Literal -from pydantic import Field +from typing import Optional, Dict, List, Literal, Any +from pydantic import Field, field_serializer, field_validator class HttpCallTemplate(CallTemplate): """REQUIRED @@ -108,6 +108,44 @@ class HttpCallTemplate(CallTemplate): body_field: Optional[str] = Field(default="body", description="The name of the single input field to be sent as the request body.") header_fields: Optional[List[str]] = Field(default=None, description="List of input fields to be sent as request headers.") + @field_serializer('auth') + def serialize_auth(self, auth: Optional[Auth]) -> Optional[dict]: + """Serialize auth to dictionary.""" + if auth is None: + return None + return AuthSerializer().to_dict(auth) + + @field_validator('auth', mode='before') + @classmethod + def validate_auth(cls, v: Any) -> Optional[Auth]: + """Validate and deserialize auth from dictionary.""" + if v is None: + return None + if isinstance(v, Auth): + return v + if isinstance(v, dict): + return AuthSerializer().validate_dict(v) + raise ValueError(f"auth must be None, Auth instance, or dict, got {type(v)}") + + @field_serializer('auth_tools') + def serialize_auth_tools(self, auth_tools: Optional[Auth]) -> Optional[dict]: + """Serialize auth_tools to dictionary.""" + if auth_tools is None: + return None + return AuthSerializer().to_dict(auth_tools) + + @field_validator('auth_tools', mode='before') + @classmethod + def validate_auth_tools(cls, v: Any) -> Optional[Auth]: + """Validate and deserialize auth_tools from dictionary.""" + if v is None: + return None + if isinstance(v, Auth): + return v + if isinstance(v, dict): + return AuthSerializer().validate_dict(v) + raise ValueError(f"auth_tools must be None, Auth instance, or dict, got {type(v)}") + class HttpCallTemplateSerializer(Serializer[HttpCallTemplate]): """REQUIRED diff --git a/plugins/communication_protocols/text/src/utcp_text/text_call_template.py b/plugins/communication_protocols/text/src/utcp_text/text_call_template.py index f013e4d..a090817 100644 --- a/plugins/communication_protocols/text/src/utcp_text/text_call_template.py +++ b/plugins/communication_protocols/text/src/utcp_text/text_call_template.py @@ -1,8 +1,8 @@ -from typing import Literal, Optional -from pydantic import Field +from typing import Literal, Optional, Any +from pydantic import Field, field_serializer, field_validator from utcp.data.call_template import CallTemplate -from utcp.data.auth import Auth +from utcp.data.auth import Auth, AuthSerializer from utcp.interfaces.serializer import Serializer from utcp.exceptions import UtcpSerializerValidationError import traceback @@ -26,6 +26,25 @@ class TextCallTemplate(CallTemplate): auth: None = None auth_tools: Optional[Auth] = Field(None, description="Authentication to apply to generated tools from OpenAPI specs.") + @field_serializer('auth_tools') + def serialize_auth_tools(self, auth_tools: Optional[Auth]) -> Optional[dict]: + """Serialize auth_tools to dictionary.""" + if auth_tools is None: + return None + return AuthSerializer().to_dict(auth_tools) + + @field_validator('auth_tools', mode='before') + @classmethod + def validate_auth_tools(cls, v: Any) -> Optional[Auth]: + """Validate and deserialize auth_tools from dictionary.""" + if v is None: + return None + if isinstance(v, Auth): + return v + if isinstance(v, dict): + return AuthSerializer().validate_dict(v) + raise ValueError(f"auth_tools must be None, Auth instance, or dict, got {type(v)}") + class TextCallTemplateSerializer(Serializer[TextCallTemplate]): """REQUIRED diff --git a/plugins/communication_protocols/text/tests/test_text_communication_protocol.py b/plugins/communication_protocols/text/tests/test_text_communication_protocol.py index 1c8376f..179b34c 100644 --- a/plugins/communication_protocols/text/tests/test_text_communication_protocol.py +++ b/plugins/communication_protocols/text/tests/test_text_communication_protocol.py @@ -372,3 +372,30 @@ async def test_text_call_template_with_auth_tools(): assert template.auth_tools == auth_tools assert template.auth is None # auth should still be None for file access + + +@pytest.mark.asyncio +async def test_text_call_template_auth_tools_serialization(): + """Test that auth_tools field properly serializes and validates from dict.""" + # Test creation from dict + template_dict = { + "name": "test-template", + "call_template_type": "text", + "file_path": "test.json", + "auth_tools": { + "auth_type": "api_key", + "api_key": "test-key", + "var_name": "Authorization", + "location": "header" + } + } + + template = TextCallTemplate(**template_dict) + assert template.auth_tools is not None + assert template.auth_tools.api_key == "test-key" + assert template.auth_tools.var_name == "Authorization" + + # Test serialization to dict + serialized = template.model_dump() + assert serialized["auth_tools"]["auth_type"] == "api_key" + assert serialized["auth_tools"]["api_key"] == "test-key" From 7933fd41599f464464ebd757868b5afb16f0f97a Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Sun, 21 Sep 2025 12:52:16 +0200 Subject: [PATCH 6/6] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9552dfa..6b899ac 100644 --- a/README.md +++ b/README.md @@ -376,13 +376,13 @@ Configuration examples for each protocol. Remember to replace `provider_type` wi "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, authentication for accessing the OpenAPI spec URL (example using ApiKeyAuth for Bearer token) + "auth": { // Optional, authentication for the HTTP request (example using ApiKeyAuth for Bearer token) "auth_type": "api_key", "api_key": "Bearer $API_KEY", // Required "var_name": "Authorization", // Optional, default: "X-Api-Key" "location": "header" // Optional, default: "header" }, - "auth_tools": { // Optional, authentication for generated tools (applied only to endpoints requiring auth per OpenAPI spec) + "auth_tools": { // Optional, authentication for converted tools, if this call template points to an openapi spec that should be automatically converted to a utcp manual (applied only to endpoints requiring auth per OpenAPI spec) "auth_type": "api_key", "api_key": "Bearer $TOOL_API_KEY", // Required "var_name": "Authorization", // Optional, default: "X-Api-Key"