Skip to content

Commit 74a11e2

Browse files
authored
Merge pull request #69 from universal-tool-calling-protocol/dev
Plugin updates
2 parents 908cd40 + fcc7856 commit 74a11e2

21 files changed

+1629
-62
lines changed

README.md

Lines changed: 22 additions & 3 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 the HTTP request (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 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)
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
},
@@ -473,7 +479,13 @@ Note the name change from `http_stream` to `streamable_http`.
473479
"name": "my_text_manual",
474480
"call_template_type": "text", // Required
475481
"file_path": "./manuals/my_manual.json", // Required
476-
"auth": null // Optional (always null for Text)
482+
"auth": null, // Optional (always null for Text)
483+
"auth_tools": { // Optional, authentication for generated tools from OpenAPI specs
484+
"auth_type": "api_key",
485+
"api_key": "Bearer ${API_TOKEN}",
486+
"var_name": "Authorization",
487+
"location": "header"
488+
}
477489
}
478490
```
479491

@@ -569,7 +581,13 @@ client = await UtcpClient.create(config={
569581
"manual_call_templates": [{
570582
"name": "github",
571583
"call_template_type": "http",
572-
"url": "https://api.github.com/openapi.json"
584+
"url": "https://api.github.com/openapi.json",
585+
"auth_tools": { # Authentication for generated tools requiring auth
586+
"auth_type": "api_key",
587+
"api_key": "Bearer ${GITHUB_TOKEN}",
588+
"var_name": "Authorization",
589+
"location": "header"
590+
}
573591
}]
574592
})
575593
```
@@ -579,6 +597,7 @@ client = await UtcpClient.create(config={
579597
-**Zero Infrastructure**: No servers to deploy or maintain
580598
-**Direct API Calls**: Native performance, no proxy overhead
581599
-**Automatic Conversion**: OpenAPI schemas → UTCP tools
600+
-**Selective Authentication**: Only protected endpoints get auth, public endpoints remain accessible
582601
-**Authentication Preserved**: API keys, OAuth2, Basic auth supported
583602
-**Multi-format Support**: JSON, YAML, OpenAPI 2.0/3.0
584603
-**Batch Processing**: Convert multiple APIs simultaneously

plugins/communication_protocols/http/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "utcp-http"
7-
version = "1.0.4"
7+
version = "1.0.5"
88
authors = [
99
{ name = "UTCP Contributors" },
1010
]

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from utcp.data.call_template import CallTemplate, CallTemplateSerializer
2-
from utcp.data.auth import Auth
2+
from utcp.data.auth import Auth, AuthSerializer
33
from utcp.interfaces.serializer import Serializer
44
from utcp.exceptions import UtcpSerializerValidationError
55
import traceback
6-
from typing import Optional, Dict, List, Literal
7-
from pydantic import Field
6+
from typing import Optional, Dict, List, Literal, Any
7+
from pydantic import Field, field_serializer, field_validator
88

99
class HttpCallTemplate(CallTemplate):
1010
"""REQUIRED
@@ -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,10 +103,30 @@ 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.")
102110

111+
@field_serializer('auth_tools')
112+
def serialize_auth_tools(self, auth_tools: Optional[Auth]) -> Optional[dict]:
113+
"""Serialize auth_tools to dictionary."""
114+
if auth_tools is None:
115+
return None
116+
return AuthSerializer().to_dict(auth_tools)
117+
118+
@field_validator('auth_tools', mode='before')
119+
@classmethod
120+
def validate_auth_tools(cls, v: Any) -> Optional[Auth]:
121+
"""Validate and deserialize auth_tools from dictionary."""
122+
if v is None:
123+
return None
124+
if isinstance(v, Auth):
125+
return v
126+
if isinstance(v, dict):
127+
return AuthSerializer().validate_dict(v)
128+
raise ValueError(f"auth_tools must be None, Auth instance, or dict, got {type(v)}")
129+
103130

104131
class HttpCallTemplateSerializer(Serializer[HttpCallTemplate]):
105132
"""REQUIRED

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
"""

0 commit comments

Comments
 (0)