Skip to content

Commit c85d2cb

Browse files
committed
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
1 parent fd72448 commit c85d2cb

File tree

6 files changed

+134
-13
lines changed

6 files changed

+134
-13
lines changed

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,12 +376,18 @@ Configuration examples for each protocol. Remember to replace `provider_type` wi
376376
"url": "https://api.example.com/users/{user_id}", // Required
377377
"http_method": "POST", // Required, default: "GET"
378378
"content_type": "application/json", // Optional, default: "application/json"
379-
"auth": { // Optional, example using ApiKeyAuth for a Bearer token. The client must prepend "Bearer " to the token.
379+
"auth": { // Optional, authentication for accessing the OpenAPI spec URL (example using ApiKeyAuth for Bearer token)
380380
"auth_type": "api_key",
381381
"api_key": "Bearer $API_KEY", // Required
382382
"var_name": "Authorization", // Optional, default: "X-Api-Key"
383383
"location": "header" // Optional, default: "header"
384384
},
385+
"auth_tools": { // Optional, authentication for generated tools (applied only to endpoints requiring auth per OpenAPI spec)
386+
"auth_type": "api_key",
387+
"api_key": "Bearer $TOOL_API_KEY", // Required
388+
"var_name": "Authorization", // Optional, default: "X-Api-Key"
389+
"location": "header" // Optional, default: "header"
390+
},
385391
"headers": { // Optional
386392
"X-Custom-Header": "value"
387393
},
@@ -569,7 +575,13 @@ client = await UtcpClient.create(config={
569575
"manual_call_templates": [{
570576
"name": "github",
571577
"call_template_type": "http",
572-
"url": "https://api.github.com/openapi.json"
578+
"url": "https://api.github.com/openapi.json",
579+
"auth_tools": { # Authentication for generated tools requiring auth
580+
"auth_type": "api_key",
581+
"api_key": "Bearer ${GITHUB_TOKEN}",
582+
"var_name": "Authorization",
583+
"location": "header"
584+
}
573585
}]
574586
})
575587
```
@@ -579,6 +591,7 @@ client = await UtcpClient.create(config={
579591
-**Zero Infrastructure**: No servers to deploy or maintain
580592
-**Direct API Calls**: Native performance, no proxy overhead
581593
-**Automatic Conversion**: OpenAPI schemas → UTCP tools
594+
-**Selective Authentication**: Only protected endpoints get auth, public endpoints remain accessible
582595
-**Authentication Preserved**: API keys, OAuth2, Basic auth supported
583596
-**Multi-format Support**: JSON, YAML, OpenAPI 2.0/3.0
584597
-**Batch Processing**: Convert multiple APIs simultaneously

plugins/communication_protocols/http/src/utcp_http/http_call_template.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ class HttpCallTemplate(CallTemplate):
4040
"var_name": "Authorization",
4141
"location": "header"
4242
},
43+
"auth_tools": {
44+
"auth_type": "api_key",
45+
"api_key": "Bearer ${TOOL_API_KEY}",
46+
"var_name": "Authorization",
47+
"location": "header"
48+
},
4349
"headers": {
4450
"X-Custom-Header": "value"
4551
},
@@ -85,7 +91,8 @@ class HttpCallTemplate(CallTemplate):
8591
url: The base URL for the HTTP endpoint. Supports path parameters like
8692
"https://api.example.com/users/{user_id}/posts/{post_id}".
8793
content_type: The Content-Type header for requests.
88-
auth: Optional authentication configuration.
94+
auth: Optional authentication configuration for accessing the OpenAPI spec URL.
95+
auth_tools: Optional authentication configuration for generated tools. Applied only to endpoints requiring auth per OpenAPI spec.
8996
headers: Optional static headers to include in all requests.
9097
body_field: Name of the tool argument to map to the HTTP request body.
9198
header_fields: List of tool argument names to map to HTTP request headers.
@@ -96,6 +103,7 @@ class HttpCallTemplate(CallTemplate):
96103
url: str
97104
content_type: str = Field(default="application/json")
98105
auth: Optional[Auth] = None
106+
auth_tools: Optional[Auth] = Field(default=None, description="Authentication configuration for generated tools (applied only to endpoints requiring auth per OpenAPI spec)")
99107
headers: Optional[Dict[str, str]] = None
100108
body_field: Optional[str] = Field(default="body", description="The name of the single input field to be sent as the request body.")
101109
header_fields: Optional[List[str]] = Field(default=None, description="List of input fields to be sent as request headers.")

plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
197197
utcp_manual = UtcpManualSerializer().validate_dict(response_data)
198198
else:
199199
logger.info(f"Assuming OpenAPI spec from '{manual_call_template.name}'. Converting to UTCP manual.")
200-
converter = OpenApiConverter(response_data, spec_url=manual_call_template.url, call_template_name=manual_call_template.name)
200+
converter = OpenApiConverter(response_data, spec_url=manual_call_template.url, call_template_name=manual_call_template.name, auth_tools=manual_call_template.auth_tools)
201201
utcp_manual = converter.convert()
202202

203203
return RegisterManualResult(

plugins/communication_protocols/http/src/utcp_http/openapi_converter.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ class OpenApiConverter:
8787
call_template_name: Normalized name for the call_template derived from the spec.
8888
"""
8989

90-
def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, call_template_name: Optional[str] = None):
90+
def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, call_template_name: Optional[str] = None, auth_tools: Optional[Auth] = None):
9191
"""Initializes the OpenAPI converter.
9292
9393
Args:
@@ -96,9 +96,12 @@ def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None,
9696
Used for base URL determination if servers are not specified.
9797
call_template_name: Optional custom name for the call_template if
9898
the specification title is not provided.
99+
auth_tools: Optional auth configuration for generated tools.
100+
Applied only to endpoints that require authentication per OpenAPI spec.
99101
"""
100102
self.spec = openapi_spec
101103
self.spec_url = spec_url
104+
self.auth_tools = auth_tools
102105
# Single counter for all placeholder variables
103106
self.placeholder_counter = 0
104107
if call_template_name is None:
@@ -160,19 +163,22 @@ def convert(self) -> UtcpManual:
160163

161164
def _extract_auth(self, operation: Dict[str, Any]) -> Optional[Auth]:
162165
"""
163-
Extracts authentication information from OpenAPI operation and global security schemes."""
166+
Extracts authentication information from OpenAPI operation and global security schemes.
167+
Uses auth_tools configuration when compatible with OpenAPI auth requirements.
168+
Supports both OpenAPI 2.0 and 3.0 security schemes.
169+
"""
164170
# First check for operation-level security requirements
165171
security_requirements = operation.get("security", [])
166172

167173
# If no operation-level security, check global security requirements
168174
if not security_requirements:
169175
security_requirements = self.spec.get("security", [])
170176

171-
# If no security requirements, return None
177+
# If no security requirements, return None (endpoint is public)
172178
if not security_requirements:
173179
return None
174180

175-
# Get security schemes - support both OpenAPI 2.0 and 3.0
181+
# Generate auth from OpenAPI security schemes - support both OpenAPI 2.0 and 3.0
176182
security_schemes = self._get_security_schemes()
177183

178184
# Process the first security requirement (most common case)
@@ -181,9 +187,47 @@ def _extract_auth(self, operation: Dict[str, Any]) -> Optional[Auth]:
181187
for scheme_name, scopes in security_req.items():
182188
if scheme_name in security_schemes:
183189
scheme = security_schemes[scheme_name]
184-
return self._create_auth_from_scheme(scheme, scheme_name)
190+
openapi_auth = self._create_auth_from_scheme(scheme, scheme_name)
191+
192+
# If compatible with auth_tools, use actual values from manual call template
193+
if self._is_auth_compatible(openapi_auth, self.auth_tools):
194+
return self.auth_tools
195+
else:
196+
return openapi_auth # Use placeholder from OpenAPI scheme
185197

186198
return None
199+
200+
def _is_auth_compatible(self, openapi_auth: Optional[Auth], auth_tools: Optional[Auth]) -> bool:
201+
"""
202+
Checks if auth_tools configuration is compatible with OpenAPI auth requirements.
203+
204+
Args:
205+
openapi_auth: Auth generated from OpenAPI security scheme
206+
auth_tools: Auth configuration from manual call template
207+
208+
Returns:
209+
True if compatible and auth_tools should be used, False otherwise
210+
"""
211+
if not openapi_auth or not auth_tools:
212+
return False
213+
214+
# Must be same auth type
215+
if type(openapi_auth) != type(auth_tools):
216+
return False
217+
218+
# For API Key auth, check header name and location compatibility
219+
if hasattr(openapi_auth, 'var_name') and hasattr(auth_tools, 'var_name'):
220+
openapi_var = openapi_auth.var_name.lower() if openapi_auth.var_name else ""
221+
tools_var = auth_tools.var_name.lower() if auth_tools.var_name else ""
222+
223+
if openapi_var != tools_var:
224+
return False
225+
226+
if hasattr(openapi_auth, 'location') and hasattr(auth_tools, 'location'):
227+
if openapi_auth.location != auth_tools.location:
228+
return False
229+
230+
return True
187231

188232
def _get_security_schemes(self) -> Dict[str, Any]:
189233
"""

plugins/communication_protocols/http/tests/test_http_communication_protocol.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,3 +694,35 @@ async def test_call_tool_openlibrary_style_url(http_transport):
694694
expected_remaining = {"format": "json"}
695695
http_transport._build_url_with_path_params(call_template.url, arguments)
696696
assert arguments == expected_remaining
697+
698+
699+
def test_auth_tools_integration():
700+
"""Test that auth_tools field is properly integrated in HttpCallTemplate."""
701+
from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth
702+
from utcp_http.http_call_template import HttpCallTemplateSerializer
703+
704+
# Create auth_tools configuration
705+
auth_tools = ApiKeyAuth(
706+
api_key="Bearer test-token",
707+
var_name="Authorization",
708+
location="header"
709+
)
710+
711+
# Create HttpCallTemplate with auth_tools
712+
call_template = HttpCallTemplate(
713+
name="test-auth-tools",
714+
url="https://api.example.com/spec.json",
715+
auth_tools=auth_tools
716+
)
717+
718+
# Verify auth_tools is stored correctly
719+
assert call_template.auth_tools is not None
720+
assert call_template.auth_tools.api_key == "Bearer test-token"
721+
assert call_template.auth_tools.var_name == "Authorization"
722+
assert call_template.auth_tools.location == "header"
723+
724+
# Verify it can be serialized (auth_type is included for security)
725+
serializer = HttpCallTemplateSerializer()
726+
serialized = serializer.to_dict(call_template)
727+
assert "auth_tools" in serialized
728+
assert serialized["auth_tools"]["auth_type"] == "api_key"

plugins/communication_protocols/http/tests/test_openapi_converter.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import sys
44
from utcp_http.openapi_converter import OpenApiConverter
55
from utcp.data.utcp_manual import UtcpManual
6+
from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth
67

78

89
@pytest.mark.asyncio
@@ -28,7 +29,30 @@ async def test_openai_spec_conversion():
2829
assert sample_tool.tool_call_template.http_method == "POST"
2930
body_schema = sample_tool.inputs.properties.get('body')
3031
assert body_schema is not None
31-
assert body_schema.properties is not None
32-
assert "messages" in body_schema.properties
33-
assert "model" in body_schema.properties
34-
assert "choices" in sample_tool.outputs.properties
32+
33+
34+
@pytest.mark.asyncio
35+
async def test_openapi_converter_with_auth_tools():
36+
"""Test OpenAPI converter with auth_tools parameter."""
37+
url = "https://api.apis.guru/v2/specs/openai.com/1.2.0/openapi.json"
38+
39+
async with aiohttp.ClientSession() as session:
40+
async with session.get(url) as response:
41+
response.raise_for_status()
42+
openapi_spec = await response.json()
43+
44+
# Test with auth_tools parameter
45+
auth_tools = ApiKeyAuth(
46+
api_key="Bearer test-token",
47+
var_name="Authorization",
48+
location="header"
49+
)
50+
51+
converter = OpenApiConverter(openapi_spec, spec_url=url, auth_tools=auth_tools)
52+
utcp_manual = converter.convert()
53+
54+
assert isinstance(utcp_manual, UtcpManual)
55+
assert len(utcp_manual.tools) > 0
56+
57+
# Verify auth_tools is stored
58+
assert converter.auth_tools == auth_tools

0 commit comments

Comments
 (0)