utcp-http 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: utcp-http
3
+ Version: 1.0.0
4
+ Summary: Universal Tool Calling Protocol (UTCP) client library for Python
5
+ Author: UTCP Contributors
6
+ License-Expression: MPL-2.0
7
+ Project-URL: Homepage, https://utcp.io
8
+ Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
9
+ Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: authlib>=1.0
18
+ Requires-Dist: aiohttp>=3.8
19
+ Requires-Dist: pyyaml>=6.0
20
+ Requires-Dist: utcp>=1.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: build; extra == "dev"
23
+ Requires-Dist: pytest; extra == "dev"
24
+ Requires-Dist: pytest-asyncio; extra == "dev"
25
+ Requires-Dist: pytest-aiohttp; extra == "dev"
26
+ Requires-Dist: pytest-cov; extra == "dev"
27
+ Requires-Dist: coverage; extra == "dev"
28
+ Requires-Dist: fastapi; extra == "dev"
29
+ Requires-Dist: uvicorn; extra == "dev"
30
+ Requires-Dist: twine; extra == "dev"
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "utcp-http"
7
+ version = "1.0.0"
8
+ authors = [
9
+ { name = "UTCP Contributors" },
10
+ ]
11
+ description = "Universal Tool Calling Protocol (UTCP) client library for Python"
12
+ readme = "README.md"
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "pydantic>=2.0",
16
+ "authlib>=1.0",
17
+ "aiohttp>=3.8",
18
+ "pyyaml>=6.0",
19
+ "utcp>=1.0"
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Developers",
24
+ "Programming Language :: Python :: 3",
25
+ "Operating System :: OS Independent",
26
+ ]
27
+ license = "MPL-2.0"
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "build",
32
+ "pytest",
33
+ "pytest-asyncio",
34
+ "pytest-aiohttp",
35
+ "pytest-cov",
36
+ "coverage",
37
+ "fastapi",
38
+ "uvicorn",
39
+ "twine",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://utcp.io"
44
+ Source = "https://github.com/universal-tool-calling-protocol/python-utcp"
45
+ Issues = "https://github.com/universal-tool-calling-protocol/python-utcp/issues"
46
+
47
+ [project.entry-points."utcp.plugins"]
48
+ http = "utcp_http:register"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ """HTTP Communication Protocol plugin for UTCP.
2
+
3
+ This plugin provides HTTP-based communication protocols including:
4
+ - Standard HTTP requests
5
+ - Server-Sent Events (SSE)
6
+ - Streamable HTTP with chunked transfer encoding
7
+ """
8
+
9
+ from utcp.plugins.discovery import register_communication_protocol, register_call_template
10
+ from utcp_http.http_communication_protocol import HttpCommunicationProtocol
11
+ from utcp_http.sse_communication_protocol import SseCommunicationProtocol
12
+ from utcp_http.streamable_http_communication_protocol import StreamableHttpCommunicationProtocol
13
+ from utcp_http.http_call_template import HttpCallTemplate, HttpCallTemplateSerializer
14
+ from utcp_http.sse_call_template import SseCallTemplate, SSECallTemplateSerializer
15
+ from utcp_http.streamable_http_call_template import StreamableHttpCallTemplate, StreamableHttpCallTemplateSerializer
16
+
17
+ def register():
18
+ # Register HTTP communication protocols
19
+ register_communication_protocol("http", HttpCommunicationProtocol())
20
+ register_communication_protocol("sse", SseCommunicationProtocol())
21
+ register_communication_protocol("streamable_http", StreamableHttpCommunicationProtocol())
22
+
23
+ # Register call template serializers
24
+ register_call_template("http", HttpCallTemplateSerializer())
25
+ register_call_template("sse", SSECallTemplateSerializer())
26
+ register_call_template("streamable_http", StreamableHttpCallTemplateSerializer())
27
+
28
+ # Export public API
29
+ __all__ = [
30
+ "HttpCommunicationProtocol",
31
+ "SseCommunicationProtocol",
32
+ "StreamableHttpCommunicationProtocol",
33
+ "HttpCallTemplate",
34
+ "SseCallTemplate",
35
+ "StreamableHttpCallTemplate",
36
+ "HttpCallTemplateSerializer",
37
+ "SSECallTemplateSerializer",
38
+ "StreamableHttpCallTemplateSerializer",
39
+ ]
@@ -0,0 +1,49 @@
1
+ from utcp.data.call_template import CallTemplate, CallTemplateSerializer
2
+ from utcp.data.auth import Auth
3
+ from utcp.interfaces.serializer import Serializer
4
+ from utcp.exceptions import UtcpSerializerValidationError
5
+ import traceback
6
+ from typing import Optional, Dict, List, Literal
7
+ from pydantic import Field
8
+
9
+ class HttpCallTemplate(CallTemplate):
10
+ """Provider configuration for HTTP-based tools.
11
+
12
+ Supports RESTful HTTP/HTTPS APIs with various HTTP methods, authentication,
13
+ custom headers, and flexible request/response handling. Supports URL path
14
+ parameters using {parameter_name} syntax. All tool arguments not mapped to
15
+ URL body, headers or query pattern parameters are passed as query parameters using '?arg_name={arg_value}'.
16
+
17
+ Attributes:
18
+ call_template_type: Always "http" for HTTP providers.
19
+ http_method: The HTTP method to use for requests.
20
+ url: The base URL for the HTTP endpoint. Supports path parameters like
21
+ "https://api.example.com/users/{user_id}/posts/{post_id}".
22
+ content_type: The Content-Type header for requests.
23
+ auth: Optional authentication configuration.
24
+ headers: Optional static headers to include in all requests.
25
+ body_field: Name of the tool argument to map to the HTTP request body.
26
+ header_fields: List of tool argument names to map to HTTP request headers.
27
+ """
28
+
29
+ call_template_type: Literal["http"] = "http"
30
+ http_method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "GET"
31
+ url: str
32
+ content_type: str = Field(default="application/json")
33
+ auth: Optional[Auth] = None
34
+ headers: Optional[Dict[str, str]] = None
35
+ body_field: Optional[str] = Field(default="body", description="The name of the single input field to be sent as the request body.")
36
+ header_fields: Optional[List[str]] = Field(default=None, description="List of input fields to be sent as request headers.")
37
+
38
+
39
+ class HttpCallTemplateSerializer(Serializer[HttpCallTemplate]):
40
+ """Serializer for HttpCallTemplate."""
41
+
42
+ def to_dict(self, obj: HttpCallTemplate) -> dict:
43
+ return obj.model_dump()
44
+
45
+ def validate_dict(self, obj: dict) -> HttpCallTemplate:
46
+ try:
47
+ return HttpCallTemplate.model_validate(obj)
48
+ except Exception as e:
49
+ raise UtcpSerializerValidationError("Invalid HttpCallTemplate: " + traceback.format_exc()) from e
@@ -0,0 +1,409 @@
1
+ """HTTP communication protocol implementation for UTCP client.
2
+
3
+ This module provides the HTTP communication protocol implementation that handles communication
4
+ with HTTP-based tool providers. It supports RESTful APIs, authentication methods,
5
+ URL path parameters, and automatic tool discovery through various formats.
6
+
7
+ Key Features:
8
+ - Multiple authentication methods (API key, Basic, OAuth2)
9
+ - URL path parameter substitution
10
+ - Automatic tool discovery from UTCP manuals, OpenAPI specs, and YAML
11
+ - Security enforcement (HTTPS or localhost only)
12
+ - Request/response handling with proper error management
13
+ """
14
+
15
+ from typing import Dict, Any, List, Optional, Callable, AsyncGenerator
16
+ import aiohttp
17
+ import json
18
+ import yaml
19
+ import base64
20
+ import re
21
+ import traceback
22
+
23
+ from utcp.interfaces.communication_protocol import CommunicationProtocol
24
+ from utcp.data.call_template import CallTemplate
25
+ from utcp.data.tool import Tool
26
+ from utcp.data.utcp_manual import UtcpManual, UtcpManualSerializer
27
+ from utcp.data.register_manual_response import RegisterManualResult
28
+ from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth
29
+ from utcp.data.auth_implementations.basic_auth import BasicAuth
30
+ from utcp.data.auth_implementations.oauth2_auth import OAuth2Auth
31
+ from utcp_http.http_call_template import HttpCallTemplate
32
+ from aiohttp import ClientSession, BasicAuth as AiohttpBasicAuth
33
+ from utcp_http.openapi_converter import OpenApiConverter
34
+ import logging
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ class HttpCommunicationProtocol(CommunicationProtocol):
39
+ """HTTP communication protocol implementation for UTCP client.
40
+
41
+ Handles communication with HTTP-based tool providers, supporting various
42
+ authentication methods, URL path parameters, and automatic tool discovery.
43
+ Enforces security by requiring HTTPS or localhost connections.
44
+
45
+ Features:
46
+ - RESTful API communication with configurable HTTP methods
47
+ - Multiple authentication: API key (header/query/cookie), Basic, OAuth2
48
+ - URL path parameter substitution from tool arguments
49
+ - Tool discovery from UTCP manuals, OpenAPI specs, and YAML
50
+ - Request body and header field mapping from tool arguments
51
+ - OAuth2 token caching and automatic refresh
52
+ - Security validation of connection URLs
53
+
54
+ Attributes:
55
+ _session: Optional aiohttp ClientSession for connection reuse.
56
+ _oauth_tokens: Cache of OAuth2 tokens by client_id.
57
+ _log: Logger function for debugging and error reporting.
58
+ """
59
+
60
+ def __init__(self, logger: Optional[Callable[[str], None]] = None):
61
+ """Initialize the HTTP transport.
62
+
63
+ Args:
64
+ logger: Optional logging function that accepts log messages.
65
+ Defaults to a no-op function if not provided.
66
+ """
67
+ self._session: Optional[aiohttp.ClientSession] = None
68
+ self._oauth_tokens: Dict[str, Dict[str, Any]] = {}
69
+
70
+ def _apply_auth(self, provider: HttpCallTemplate, headers: Dict[str, str], query_params: Dict[str, Any]) -> tuple:
71
+ """Apply authentication to the request based on the provider's auth configuration.
72
+
73
+ Returns:
74
+ tuple: (auth_obj, cookies) where auth_obj is for aiohttp basic auth and cookies is a dict
75
+ """
76
+ auth = None
77
+ cookies = {}
78
+
79
+ if provider.auth:
80
+ if isinstance(provider.auth, ApiKeyAuth):
81
+ if provider.auth.api_key:
82
+ if provider.auth.location == "header":
83
+ headers[provider.auth.var_name] = provider.auth.api_key
84
+ elif provider.auth.location == "query":
85
+ query_params[provider.auth.var_name] = provider.auth.api_key
86
+ elif provider.auth.location == "cookie":
87
+ cookies[provider.auth.var_name] = provider.auth.api_key
88
+ else:
89
+ logger.error("API key not found for ApiKeyAuth.")
90
+ raise ValueError("API key for ApiKeyAuth not found.")
91
+
92
+ elif isinstance(provider.auth, BasicAuth):
93
+ auth = AiohttpBasicAuth(provider.auth.username, provider.auth.password)
94
+
95
+ elif isinstance(provider.auth, OAuth2Auth):
96
+ # OAuth2 tokens are always sent in the Authorization header
97
+ # We'll handle this separately since it requires async token retrieval
98
+ pass
99
+
100
+ return auth, cookies
101
+
102
+ async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
103
+ """Register a manual and its tools.
104
+
105
+ Args:
106
+ caller: The UTCP client that is calling this method.
107
+ manual_call_template: The call template of the manual to register.
108
+
109
+ Returns:
110
+ RegisterManualResult object containing the call template and manual.
111
+ """
112
+ if not isinstance(manual_call_template, HttpCallTemplate):
113
+ raise ValueError("HttpCommunicationProtocol can only be used with HttpCallTemplate")
114
+
115
+ try:
116
+ url = manual_call_template.url
117
+
118
+ # Security check: Enforce HTTPS or localhost to prevent MITM attacks
119
+ if not (url.startswith("https://") or url.startswith("http://localhost") or url.startswith("http://127.0.0.1")):
120
+ raise ValueError(
121
+ f"Security error: URL must use HTTPS or start with 'http://localhost' or 'http://127.0.0.1'. Got: {url}. "
122
+ "Non-secure URLs are vulnerable to man-in-the-middle attacks."
123
+ )
124
+
125
+ logger.info(f"Discovering tools from '{manual_call_template.name}' (HTTP) at {url}")
126
+
127
+ # Use the call template's configuration (headers, auth, HTTP method, etc.)
128
+ request_headers = manual_call_template.headers.copy() if manual_call_template.headers else {}
129
+ body_content = None
130
+ query_params = {}
131
+
132
+ # Handle authentication
133
+ auth, cookies = self._apply_auth(manual_call_template, request_headers, query_params)
134
+
135
+ # Handle OAuth2 separately since it requires async token retrieval
136
+ if manual_call_template.auth and isinstance(manual_call_template.auth, OAuth2Auth):
137
+ token = await self._handle_oauth2(manual_call_template.auth)
138
+ request_headers["Authorization"] = f"Bearer {token}"
139
+
140
+ # Handle body content if specified
141
+ if manual_call_template.body_field:
142
+ # For discovery, we typically don't have body content, but support it if needed
143
+ body_content = None
144
+
145
+ async with aiohttp.ClientSession() as session:
146
+ try:
147
+ # Set content-type header if body is provided and header not already set
148
+ if body_content is not None and "Content-Type" not in request_headers:
149
+ request_headers["Content-Type"] = manual_call_template.content_type
150
+
151
+ # Prepare body content based on content type
152
+ data = None
153
+ json_data = None
154
+ if body_content is not None:
155
+ if "application/json" in request_headers.get("Content-Type", ""):
156
+ json_data = body_content
157
+ else:
158
+ data = body_content
159
+
160
+ # Make the request with the call template's HTTP method
161
+ method = manual_call_template.http_method.lower()
162
+ request_method = getattr(session, method)
163
+
164
+ async with request_method(
165
+ url,
166
+ params=query_params,
167
+ headers=request_headers,
168
+ auth=auth,
169
+ json=json_data,
170
+ data=data,
171
+ cookies=cookies,
172
+ timeout=aiohttp.ClientTimeout(total=10.0)
173
+ ) as response:
174
+ response.raise_for_status() # Raise exception for 4XX/5XX responses
175
+
176
+ # Check content type to determine how to parse the response
177
+ content_type = response.headers.get('Content-Type', '')
178
+ response_text = await response.text()
179
+
180
+ if 'yaml' in content_type or url.endswith(('.yaml', '.yml')):
181
+ response_data = yaml.safe_load(response_text)
182
+ else:
183
+ response_data = json.loads(response_text)
184
+
185
+ # Check if the response is a UTCP manual or an OpenAPI spec
186
+ if "utcp_version" in response_data and "tools" in response_data:
187
+ logger.info(f"Detected UTCP manual from '{manual_call_template.name}'.")
188
+ utcp_manual = UtcpManualSerializer().validate_dict(response_data)
189
+ else:
190
+ logger.info(f"Assuming OpenAPI spec from '{manual_call_template.name}'. Converting to UTCP manual.")
191
+ converter = OpenApiConverter(response_data, spec_url=manual_call_template.url, call_template_name=manual_call_template.name)
192
+ utcp_manual = converter.convert()
193
+
194
+ return RegisterManualResult(
195
+ success=True,
196
+ manual_call_template=manual_call_template,
197
+ manual=utcp_manual,
198
+ errors=[]
199
+ )
200
+ except aiohttp.ClientResponseError as e:
201
+ error_msg = f"Error connecting to HTTP provider '{manual_call_template.name}': {e}"
202
+ logger.error(error_msg)
203
+ return RegisterManualResult(
204
+ success=False,
205
+ manual_call_template=manual_call_template,
206
+ manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
207
+ errors=[error_msg]
208
+ )
209
+ except (json.JSONDecodeError, yaml.YAMLError) as e:
210
+ error_msg = f"Error parsing spec from HTTP provider '{manual_call_template.name}': {e}"
211
+ logger.error(error_msg)
212
+ return RegisterManualResult(
213
+ success=False,
214
+ manual_call_template=manual_call_template,
215
+ manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
216
+ errors=[error_msg]
217
+ )
218
+ except Exception as e:
219
+ error_msg = f"Unexpected error discovering tools from HTTP provider '{manual_call_template.name}': {traceback.format_exc()}"
220
+ logger.error(error_msg)
221
+ return RegisterManualResult(
222
+ success=False,
223
+ manual_call_template=manual_call_template,
224
+ manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
225
+ errors=[error_msg]
226
+ )
227
+
228
+ async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None:
229
+ """Deregister a manual and its tools.
230
+
231
+ Deregistering a manual is a no-op for the stateless HTTP communication protocol.
232
+ """
233
+ pass
234
+
235
+ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
236
+ """Execute a tool call through this transport.
237
+
238
+ Args:
239
+ caller: The UTCP client that is calling this method.
240
+ tool_name: Name of the tool to call (may include provider prefix).
241
+ tool_args: Dictionary of arguments to pass to the tool.
242
+ tool_call_template: Call template of the tool to call.
243
+
244
+ Returns:
245
+ The tool's response, with type depending on the tool's output schema.
246
+ """
247
+ if not isinstance(tool_call_template, HttpCallTemplate):
248
+ raise ValueError("HttpCommunicationProtocol can only be used with HttpCallTemplate")
249
+
250
+ request_headers = tool_call_template.headers.copy() if tool_call_template.headers else {}
251
+ body_content = None
252
+ remaining_args = tool_args.copy()
253
+
254
+ # Handle header fields
255
+ if tool_call_template.header_fields:
256
+ for field_name in tool_call_template.header_fields:
257
+ if field_name in remaining_args:
258
+ request_headers[field_name] = str(remaining_args.pop(field_name))
259
+
260
+ # Handle body field
261
+ if tool_call_template.body_field and tool_call_template.body_field in remaining_args:
262
+ body_content = remaining_args.pop(tool_call_template.body_field)
263
+
264
+ # Build the URL with path parameters substituted
265
+ url = self._build_url_with_path_params(tool_call_template.url, remaining_args)
266
+
267
+ # The rest of the arguments are query parameters
268
+ query_params = remaining_args
269
+
270
+ # Handle authentication
271
+ auth, cookies = self._apply_auth(tool_call_template, request_headers, query_params)
272
+
273
+ # Handle OAuth2 separately since it requires async token retrieval
274
+ if tool_call_template.auth and isinstance(tool_call_template.auth, OAuth2Auth):
275
+ token = await self._handle_oauth2(tool_call_template.auth)
276
+ request_headers["Authorization"] = f"Bearer {token}"
277
+
278
+ async with aiohttp.ClientSession() as session:
279
+ try:
280
+ # Set content-type header if body is provided and header not already set
281
+ if body_content is not None and "Content-Type" not in request_headers:
282
+ request_headers["Content-Type"] = tool_call_template.content_type
283
+
284
+ # Prepare body content based on content type
285
+ data = None
286
+ json_data = None
287
+ if body_content is not None:
288
+ if "application/json" in request_headers.get("Content-Type", ""):
289
+ json_data = body_content
290
+ else:
291
+ data = body_content
292
+
293
+ # Make the request with the appropriate HTTP method
294
+ method = tool_call_template.http_method.lower()
295
+ request_method = getattr(session, method)
296
+
297
+ async with request_method(
298
+ url,
299
+ params=query_params,
300
+ headers=request_headers,
301
+ auth=auth,
302
+ json=json_data,
303
+ data=data,
304
+ cookies=cookies,
305
+ timeout=aiohttp.ClientTimeout(total=30.0)
306
+ ) as response:
307
+ response.raise_for_status()
308
+ return await response.json()
309
+
310
+ except aiohttp.ClientResponseError as e:
311
+ logger.error(f"Error calling tool '{tool_name}' on call template '{tool_call_template.name}': {e}")
312
+ raise
313
+ except Exception as e:
314
+ logger.error(f"Unexpected error calling tool '{tool_name}': {e}")
315
+ raise
316
+
317
+ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]:
318
+ """Execute a tool call through this transport streamingly.
319
+
320
+ Args:
321
+ caller: The UTCP client that is calling this method.
322
+ tool_name: Name of the tool to call (may include provider prefix).
323
+ tool_args: Dictionary of arguments to pass to the tool.
324
+ tool_call_template: Call template of the tool to call.
325
+
326
+ Returns:
327
+ An async generator that yields the tool's response.
328
+ """
329
+ # For HTTP, streaming is not typically supported, so we'll just yield the complete response
330
+ result = await self.call_tool(caller, tool_name, tool_args, tool_call_template)
331
+ yield result
332
+
333
+ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str:
334
+ """Handles OAuth2 client credentials flow, trying both body and auth header methods."""
335
+ client_id = auth_details.client_id
336
+
337
+ if client_id in self._oauth_tokens:
338
+ return self._oauth_tokens[client_id]["access_token"]
339
+
340
+ async with aiohttp.ClientSession() as session:
341
+ # Method 1: Send credentials in the request body
342
+ try:
343
+ logger.info("Attempting OAuth2 token fetch with credentials in body.")
344
+ body_data = {
345
+ 'grant_type': 'client_credentials',
346
+ 'client_id': auth_details.client_id,
347
+ 'client_secret': auth_details.client_secret,
348
+ 'scope': auth_details.scope
349
+ }
350
+ async with session.post(auth_details.token_url, data=body_data) as response:
351
+ response.raise_for_status()
352
+ token_response = await response.json()
353
+ self._oauth_tokens[client_id] = token_response
354
+ return token_response["access_token"]
355
+ except aiohttp.ClientError as e:
356
+ logger.error(f"OAuth2 with credentials in body failed: {e}. Trying Basic Auth header.")
357
+
358
+ # Method 2: Send credentials as Basic Auth header
359
+ try:
360
+ logger.info("Attempting OAuth2 token fetch with Basic Auth header.")
361
+ header_auth = AiohttpBasicAuth(auth_details.client_id, auth_details.client_secret)
362
+ header_data = {
363
+ 'grant_type': 'client_credentials',
364
+ 'scope': auth_details.scope
365
+ }
366
+ async with session.post(auth_details.token_url, data=header_data, auth=header_auth) as response:
367
+ response.raise_for_status()
368
+ token_response = await response.json()
369
+ self._oauth_tokens[client_id] = token_response
370
+ return token_response["access_token"]
371
+ except aiohttp.ClientError as e:
372
+ logger.error(f"OAuth2 with Basic Auth header also failed: {e}")
373
+
374
+ def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, Any]) -> str:
375
+ """Build URL by substituting path parameters from arguments.
376
+
377
+ Args:
378
+ url_template: URL template with path parameters in {param_name} format
379
+ tool_args: Dictionary of arguments that will be modified to remove used path parameters
380
+
381
+ Returns:
382
+ URL with path parameters substituted
383
+
384
+ Example:
385
+ url_template = "https://api.example.com/users/{user_id}/posts/{post_id}"
386
+ tool_args = {"user_id": "123", "post_id": "456", "limit": "10"}
387
+ Returns: "https://api.example.com/users/123/posts/456"
388
+ And modifies tool_args to: {"limit": "10"}
389
+ """
390
+ # Find all path parameters in the URL template
391
+ path_params = re.findall(r'\{([^}]+)\}', url_template)
392
+
393
+ url = url_template
394
+ for param_name in path_params:
395
+ if param_name in tool_args:
396
+ # Replace the parameter in the URL
397
+ param_value = str(tool_args[param_name])
398
+ url = url.replace(f'{{{param_name}}}', param_value)
399
+ # Remove the parameter from arguments so it's not used as a query parameter
400
+ tool_args.pop(param_name)
401
+ else:
402
+ raise ValueError(f"Missing required path parameter: {param_name}")
403
+
404
+ # Check if there are any unreplaced path parameters
405
+ remaining_params = re.findall(r'\{([^}]+)\}', url)
406
+ if remaining_params:
407
+ raise ValueError(f"Missing required path parameters: {remaining_params}")
408
+
409
+ return url