From f1c33a04bead3e6fbca5f3914957b6d0c9a76fd8 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:23:04 +0200 Subject: [PATCH 1/4] Fix issues in v1.0.0 --- core/src/utcp/data/tool.py | 8 ++- core/src/utcp/data/utcp_client_config.py | 21 +++++-- .../implementations/in_mem_tool_repository.py | 17 +++--- .../filter_dict_post_processor.py | 2 +- core/src/utcp/implementations/tag_search.py | 55 +---------------- .../utcp/interfaces/variable_substitutor.py | 1 - core/tests/client/test_utcp_client.py | 59 +++++++++++++++---- .../utcp_http/http_communication_protocol.py | 4 +- .../http/src/utcp_http/openapi_converter.py | 15 ++--- .../utcp_http/sse_communication_protocol.py | 4 +- .../streamable_http_communication_protocol.py | 47 ++++++--------- .../tests/test_sse_communication_protocol.py | 6 +- .../text/pyproject.toml | 3 +- .../utcp_text/text_communication_protocol.py | 18 +++--- 14 files changed, 126 insertions(+), 134 deletions(-) diff --git a/core/src/utcp/data/tool.py b/core/src/utcp/data/tool.py index fb85e0d..d1262ce 100644 --- a/core/src/utcp/data/tool.py +++ b/core/src/utcp/data/tool.py @@ -41,7 +41,9 @@ class JsonSchema(BaseModel): maxLength: Optional[int] = None model_config = { - "populate_by_name": True, # replaces allow_population_by_field_name + "validate_by_name": True, + "validate_by_alias": True, + "serialize_by_alias": True, "extra": "allow" } @@ -49,7 +51,7 @@ class JsonSchema(BaseModel): class JsonSchemaSerializer(Serializer[JsonSchema]): def to_dict(self, obj: JsonSchema) -> dict: - return obj.model_dump() + return obj.model_dump(by_alias=True) def validate_dict(self, obj: dict) -> JsonSchema: try: @@ -95,7 +97,7 @@ def validate_call_template(cls, v: Union[CallTemplate, dict]): class ToolSerializer(Serializer[Tool]): def to_dict(self, obj: Tool) -> dict: - return obj.model_dump() + return obj.model_dump(by_alias=True) def validate_dict(self, obj: dict) -> Tool: try: diff --git a/core/src/utcp/data/utcp_client_config.py b/core/src/utcp/data/utcp_client_config.py index 2112f73..5edd9c8 100644 --- a/core/src/utcp/data/utcp_client_config.py +++ b/core/src/utcp/data/utcp_client_config.py @@ -23,12 +23,21 @@ class UtcpClientConfig(BaseModel): 3. Environment variables Attributes: - variables: Direct variable definitions as key-value pairs. - These take precedence over other variable sources. - providers_file_path: Optional path to a file containing provider - configurations. Supports JSON and YAML formats. - load_variables_from: List of variable loaders to use for - variable resolution. Loaders are consulted in order. + variables (Optional[Dict[str, str]]): A dictionary of directly-defined + variables for substitution. + load_variables_from (Optional[List[VariableLoader]]): A list of + variable loader configurations for loading variables from external + sources like .env files or remote services. + tool_repository (ConcurrentToolRepository): Configuration for the tool + repository, which manages the storage and retrieval of tools. + Defaults to an in-memory repository. + tool_search_strategy (ToolSearchStrategy): Configuration for the tool + search strategy, defining how tools are looked up. Defaults to a + tag and description-based search. + post_processing (List[ToolPostProcessor]): A list of tool post-processor + configurations to be applied after a tool call. + manual_call_templates (List[CallTemplate]): A list of manually defined + call templates for registering tools that don't have a provider. Example: ```python diff --git a/core/src/utcp/implementations/in_mem_tool_repository.py b/core/src/utcp/implementations/in_mem_tool_repository.py index 0a6efbc..a51642a 100644 --- a/core/src/utcp/implementations/in_mem_tool_repository.py +++ b/core/src/utcp/implementations/in_mem_tool_repository.py @@ -76,32 +76,35 @@ async def remove_tool(self, tool_name: str) -> bool: async def get_tool(self, tool_name: str) -> Optional[Tool]: async with self._rwlock.read(): - return self._tools_by_name.get(tool_name) + tool = self._tools_by_name.get(tool_name) + return tool.model_copy(deep=True) if tool else None async def get_tools(self) -> List[Tool]: async with self._rwlock.read(): - return list(self._tools_by_name.values()) + return [t.model_copy(deep=True) for t in self._tools_by_name.values()] async def get_tools_by_manual(self, manual_name: str) -> Optional[List[Tool]]: async with self._rwlock.read(): manual = self._manuals.get(manual_name) - return manual.tools if manual is not None else None + return [t.model_copy(deep=True) for t in manual.tools] if manual is not None else None async def get_manual(self, manual_name: str) -> Optional[UtcpManual]: async with self._rwlock.read(): - return self._manuals.get(manual_name) + manual = self._manuals.get(manual_name) + return manual.model_copy(deep=True) if manual else None async def get_manuals(self) -> List[UtcpManual]: async with self._rwlock.read(): - return list(self._manuals.values()) + return [m.model_copy(deep=True) for m in self._manuals.values()] async def get_manual_call_template(self, manual_call_template_name: str) -> Optional[CallTemplate]: async with self._rwlock.read(): - return self._manual_call_templates.get(manual_call_template_name) + manual_call_template = self._manual_call_templates.get(manual_call_template_name) + return manual_call_template.model_copy(deep=True) if manual_call_template else None async def get_manual_call_templates(self) -> List[CallTemplate]: async with self._rwlock.read(): - return list(self._manual_call_templates.values()) + return [m.model_copy(deep=True) for m in self._manual_call_templates.values()] class InMemToolRepositoryConfigSerializer(Serializer[InMemToolRepository]): def to_dict(self, obj: InMemToolRepository) -> dict: diff --git a/core/src/utcp/implementations/post_processors/filter_dict_post_processor.py b/core/src/utcp/implementations/post_processors/filter_dict_post_processor.py index fe6d082..10d9573 100644 --- a/core/src/utcp/implementations/post_processors/filter_dict_post_processor.py +++ b/core/src/utcp/implementations/post_processors/filter_dict_post_processor.py @@ -41,7 +41,7 @@ def _filter_dict_exclude_keys(self, result: Any) -> Any: new_result = {} for key, value in result.items(): if key not in self.exclude_keys: - new_result[key] = self._filter_dict(value) + new_result[key] = self._filter_dict_exclude_keys(value) return new_result if isinstance(result, list): diff --git a/core/src/utcp/implementations/tag_search.py b/core/src/utcp/implementations/tag_search.py index b35e9db..2232be5 100644 --- a/core/src/utcp/implementations/tag_search.py +++ b/core/src/utcp/implementations/tag_search.py @@ -1,10 +1,3 @@ -"""Tag-based tool search strategy implementation. - -This module provides a search strategy that ranks tools based on tag matches -and description keyword matches. It implements a weighted scoring system where -explicit tag matches receive higher scores than description word matches. -""" - from utcp.interfaces.tool_search_strategy import ToolSearchStrategy from typing import List, Tuple, Optional, Literal from utcp.data.tool import Tool @@ -13,56 +6,11 @@ from utcp.interfaces.serializer import Serializer class TagAndDescriptionWordMatchStrategy(ToolSearchStrategy): - """Tag and description word match search strategy for UTCP tools. - - Implements a weighted scoring algorithm that matches search queries against - tool tags and descriptions. Explicit tag matches receive full weight while - description word matches receive reduced weight. - - Scoring Algorithm: - - Exact tag matches: Weight 1.0 - - Tag word matches: Weight equal to description_weight - - Description word matches: Weight equal to description_weight - - Only considers description words longer than 2 characters - - Examples: - >>> strategy = TagAndDescriptionWordMatchStrategy(description_weight=0.3) - >>> tools = await strategy.search_tools("weather api", limit=5) - >>> # Returns tools with "weather" or "api" tags/descriptions - - Attributes: - description_weight: Weight multiplier for description matches (0.0-1.0). - """ tool_search_strategy_type: Literal["tag_and_description_word_match"] = "tag_and_description_word_match" description_weight: float = 1 tag_weight: float = 3 async def search_tools(self, tool_repository: ConcurrentToolRepository, query: str, limit: int = 10, any_of_tags_required: Optional[List[str]] = None) -> List[Tool]: - """Search tools using tag and description matching. - - Implements a weighted scoring system that ranks tools based on how well - their tags and descriptions match the search query. Normalizes the query - and uses word-based matching with configurable weights. - - Scoring Details: - - Exact tag matches in query: +1.0 points - - Individual tag words matching query words: +description_weight points - - Description words matching query words: +description_weight points - - Only description words > 2 characters are considered - - Args: - query: Search query string. Case-insensitive, word-based matching. - limit: Maximum number of tools to return. Must be >= 0. - any_of_tags_required: Optional list of tags where one of them must be present in the tool's tags - for it to be considered a match. - - Returns: - List of Tool objects ranked by relevance score (highest first). - Empty list if no tools match or repository is empty. - - Raises: - ValueError: If limit is negative. - """ if limit < 0: raise ValueError("limit must be non-negative") # Normalize query to lowercase and split into words @@ -74,7 +22,8 @@ async def search_tools(self, tool_repository: ConcurrentToolRepository, query: s tools: List[Tool] = await tool_repository.get_tools() if any_of_tags_required is not None and len(any_of_tags_required) > 0: - tools = [tool for tool in tools if any(tag in tool.tags for tag in any_of_tags_required)] + any_of_tags_required = [tag.lower() for tag in any_of_tags_required] + tools = [tool for tool in tools if any(tag.lower() in any_of_tags_required for tag in tool.tags)] # Calculate scores for each tool tool_scores: List[Tuple[Tool, float]] = [] diff --git a/core/src/utcp/interfaces/variable_substitutor.py b/core/src/utcp/interfaces/variable_substitutor.py index 0641958..8301044 100644 --- a/core/src/utcp/interfaces/variable_substitutor.py +++ b/core/src/utcp/interfaces/variable_substitutor.py @@ -17,7 +17,6 @@ def substitute(self, obj: dict | list | str, config: UtcpClientConfig, variable_ Args: obj: Object containing potential variable references to substitute. - Can be dict, list, str, or any other type. config: UTCP client configuration containing variable definitions and loaders. variable_namespace: Optional variable namespace. diff --git a/core/tests/client/test_utcp_client.py b/core/tests/client/test_utcp_client.py index 909c501..ddae7e7 100644 --- a/core/tests/client/test_utcp_client.py +++ b/core/tests/client/test_utcp_client.py @@ -185,6 +185,12 @@ async def sample_tools(): ] +@pytest.fixture +def isolated_communication_protocols(monkeypatch): + """Isolates the CommunicationProtocol registry for each test.""" + monkeypatch.setattr(CommunicationProtocol, "communication_protocols", {}) + + @pytest_asyncio.fixture async def utcp_client(): """Fixture for UtcpClient.""" @@ -245,7 +251,7 @@ async def test_create_with_utcp_config(self): assert client.config is config @pytest.mark.asyncio - async def test_register_manual(self, utcp_client, sample_tools): + async def test_register_manual(self, utcp_client, sample_tools, isolated_communication_protocols): """Test registering a manual.""" http_call_template = HttpCallTemplate( name="test_manual", @@ -286,7 +292,7 @@ async def test_register_manual_unsupported_type(self, utcp_client): await utcp_client.register_manual(call_template) @pytest.mark.asyncio - async def test_register_manual_name_sanitization(self, utcp_client, sample_tools): + async def test_register_manual_name_sanitization(self, utcp_client, sample_tools, isolated_communication_protocols): """Test that manual names are sanitized.""" call_template = HttpCallTemplate( name="test-manual.with/special@chars", @@ -306,7 +312,7 @@ async def test_register_manual_name_sanitization(self, utcp_client, sample_tools assert result.manual.tools[0].name == "test_manual_with_special_chars.http_tool" @pytest.mark.asyncio - async def test_deregister_manual(self, utcp_client, sample_tools): + async def test_deregister_manual(self, utcp_client, sample_tools, isolated_communication_protocols): """Test deregistering a manual.""" call_template = HttpCallTemplate( name="test_manual", @@ -341,7 +347,7 @@ async def test_deregister_nonexistent_manual(self, utcp_client): assert result is False @pytest.mark.asyncio - async def test_call_tool(self, utcp_client, sample_tools): + async def test_call_tool(self, utcp_client, sample_tools, isolated_communication_protocols): """Test calling a tool.""" client = utcp_client call_template = HttpCallTemplate( @@ -375,7 +381,7 @@ async def test_call_tool_nonexistent_manual(self, utcp_client): await client.call_tool("nonexistent.tool", {"param": "value"}) @pytest.mark.asyncio - async def test_call_tool_nonexistent_tool(self, utcp_client, sample_tools): + async def test_call_tool_nonexistent_tool(self, utcp_client, sample_tools, isolated_communication_protocols): """Test calling a nonexistent tool.""" client = utcp_client call_template = HttpCallTemplate( @@ -396,7 +402,7 @@ async def test_call_tool_nonexistent_tool(self, utcp_client, sample_tools): await client.call_tool("test_manual.nonexistent", {"param": "value"}) @pytest.mark.asyncio - async def test_search_tools(self, utcp_client, sample_tools): + async def test_search_tools(self, utcp_client, sample_tools, isolated_communication_protocols): """Test searching for tools.""" client = utcp_client # Clear any existing manuals from other tests to ensure a clean slate @@ -422,7 +428,7 @@ async def test_search_tools(self, utcp_client, sample_tools): assert "http" in results[0].name.lower() or "http" in results[0].description.lower() @pytest.mark.asyncio - async def test_get_required_variables_for_manual_and_tools(self, utcp_client): + async def test_get_required_variables_for_manual_and_tools(self, utcp_client, isolated_communication_protocols): """Test getting required variables for a manual.""" client = utcp_client call_template = HttpCallTemplate( @@ -477,7 +483,7 @@ class TestUtcpClientManualCallTemplateLoading: """Test call template loading functionality.""" @pytest.mark.asyncio - async def test_load_manual_call_templates_from_file(self): + async def test_load_manual_call_templates_from_file(self, isolated_communication_protocols): """Test loading call templates from a JSON file.""" config_data = { "manual_call_templates": [ @@ -536,7 +542,7 @@ async def test_load_manual_call_templates_invalid_json(self): os.unlink(temp_file) @pytest.mark.asyncio - async def test_load_manual_call_templates_with_variables(self): + async def test_load_manual_call_templates_with_variables(self, isolated_communication_protocols): """Test loading call templates with variable substitution.""" config_data = { "variables": { @@ -663,7 +669,7 @@ async def test_empty_call_template_file(self): os.unlink(temp_file) @pytest.mark.asyncio - async def test_register_manual_with_existing_name(self, utcp_client): + async def test_register_manual_with_existing_name(self, utcp_client, isolated_communication_protocols): """Test registering a manual with an existing name should raise an error.""" client = utcp_client template1 = HttpCallTemplate( @@ -717,3 +723,36 @@ async def test_load_call_templates_wrong_format(self): await UtcpClient.create(config=temp_file) finally: os.unlink(temp_file) + + +class TestToolSerialization: + """Test Tool and JsonSchema serialization.""" + + def test_json_schema_serialization_by_alias(self): + """Test that JsonSchema serializes using field aliases.""" + schema = JsonSchema( + schema_="http://json-schema.org/draft-07/schema#", + id_="test_schema", + type="object", + properties={ + "param": JsonSchema(type="string") + } + ) + + serialized_schema = schema.model_dump() + + assert "$schema" in serialized_schema + assert "$id" in serialized_schema + assert serialized_schema["$schema"] == "http://json-schema.org/draft-07/schema#" + assert serialized_schema["$id"] == "test_schema" + + def test_tool_serialization_by_alias(self, sample_tools): + """Test that Tool serializes its JsonSchema fields by alias.""" + tool = sample_tools[0] + tool.inputs.schema_ = "http://json-schema.org/draft-07/schema#" + + serialized_tool = tool.model_dump() + + assert "inputs" in serialized_tool + assert "$schema" in serialized_tool["inputs"] + assert serialized_tool["inputs"]["$schema"] == "http://json-schema.org/draft-07/schema#" 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 b0cf39f..ef7350a 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 @@ -19,6 +19,7 @@ import base64 import re import traceback +from urllib.parse import quote from utcp.interfaces.communication_protocol import CommunicationProtocol from utcp.data.call_template import CallTemplate @@ -394,7 +395,8 @@ def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, An for param_name in path_params: if param_name in tool_args: # Replace the parameter in the URL - param_value = str(tool_args[param_name]) + # URL-encode the parameter value to prevent path injection + param_value = quote(str(tool_args[param_name])) url = url.replace(f'{{{param_name}}}', param_value) # Remove the parameter from arguments so it's not used as a query parameter tool_args.pop(param_name) 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 696600a..82a3786 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -74,12 +74,11 @@ def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, self.placeholder_counter = 0 # If call_template_name is None then get the first word in spec.info.title if call_template_name is None: - title = openapi_spec.get("info", {}).get("title", "openapi_call_template_" + uuid.uuid4().hex) - # Replace characters that are invalid for identifiers - invalid_chars = " -.,!?'\"\\/()[]{}#@$%^&*+=~`|;:<>" - self.call_template_name = ''.join('_' if c in invalid_chars else c for c in title) - else: - self.call_template_name = call_template_name + call_template_name = "openapi_call_template_" + uuid.uuid4().hex + title = openapi_spec.get("info", {}).get("title", call_template_name) + # Replace characters that are invalid for identifiers + invalid_chars = " -.,!?'\"\\/()[]{}#@$%^&*+=~`|;:<>" + self.call_template_name = ''.join('_' if c in invalid_chars else c for c in title) def _increment_placeholder_counter(self) -> int: """Increments the global counter and returns the new value. @@ -299,13 +298,11 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u outputs = self._extract_outputs(operation) auth = self._extract_auth(operation) - call_template_name = self.spec.get("info", {}).get("title", "call_template_" + uuid.uuid4().hex) - # Combine base URL and path, ensuring no double slashes full_url = base_url.rstrip('/') + '/' + path.lstrip('/') call_template = HttpCallTemplate( - name=call_template_name, + name=self.call_template_name, http_method=method.upper(), url=full_url, body_field=body_field if body_field else None, diff --git a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py index 0457efa..17eae7a 100644 --- a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py @@ -3,6 +3,7 @@ import json import asyncio import re +from urllib.parse import quote import base64 from utcp.interfaces.communication_protocol import CommunicationProtocol @@ -327,7 +328,8 @@ def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, An for param_name in path_params: if param_name in tool_args: # Replace the parameter in the URL - param_value = str(tool_args[param_name]) + # URL-encode the parameter value to prevent path injection + param_value = quote(str(tool_args[param_name])) url = url.replace(f'{{{param_name}}}', param_value) # Remove the parameter from arguments so it's not used as a query parameter tool_args.pop(param_name) diff --git a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py index d15cd51..c81c548 100644 --- a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py @@ -2,6 +2,7 @@ import aiohttp import json import re +from urllib.parse import quote from utcp.interfaces.communication_protocol import CommunicationProtocol from utcp.data.call_template import CallTemplate @@ -25,7 +26,6 @@ class StreamableHttpCommunicationProtocol(CommunicationProtocol): def __init__(self): self._oauth_tokens: Dict[str, Dict[str, Any]] = {} - self._active_connections: Dict[str, Tuple[ClientResponse, ClientSession]] = {} def _apply_auth(self, provider: StreamableHttpCallTemplate, headers: Dict[str, str], query_params: Dict[str, Any]) -> tuple: """Apply authentication to the request based on the provider's auth configuration. @@ -61,14 +61,7 @@ def _apply_auth(self, provider: StreamableHttpCallTemplate, headers: Dict[str, s async def close(self): """Close all active connections and clear internal state.""" - logger.info("Closing all active HTTP stream connections.") - for provider_name, (response, session) in list(self._active_connections.items()): - logger.info(f"Closing connection for provider: {provider_name}") - if not response.closed: - response.close() # Close the response - if not session.closed: - await session.close() - self._active_connections.clear() + logger.info("Closing StreamableHttpCommunicationProtocol.") self._oauth_tokens.clear() async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult: @@ -172,17 +165,8 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R ) async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None: - """Deregister a StreamableHttp manual and close any active connections.""" - template_name = manual_call_template.name - if template_name in self._active_connections: - logger.info(f"Closing active HTTP stream connection for template '{template_name}'") - response, session = self._active_connections.pop(template_name) - if not response.closed: - response.close() - if not session.closed: - await session.close() - else: - logger.info(f"No active connection found for template '{template_name}'") + """Deregister a StreamableHttp manual. This is a no-op for the stateless streamable HTTP protocol.""" + logger.info(f"Deregistering manual '{manual_call_template.name}'. No active connection to close.") async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any: """Execute a tool call through StreamableHttp transport.""" @@ -233,8 +217,10 @@ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, token = await self._handle_oauth2(tool_call_template.auth) request_headers["Authorization"] = f"Bearer {token}" - session = ClientSession() + session = None + response = None try: + session = ClientSession() timeout_seconds = tool_call_template.timeout / 1000 if tool_call_template.timeout else 60.0 timeout = aiohttp.ClientTimeout(total=timeout_seconds) @@ -261,14 +247,17 @@ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, ) response.raise_for_status() - self._active_connections[tool_call_template.name] = (response, session) async for chunk in self._process_http_stream(response, tool_call_template.chunk_size, tool_call_template.name): yield chunk except Exception as e: - await session.close() - logger.error(f"Error establishing HTTP stream connection to '{tool_call_template.name}': {e}") + logger.error(f"Error during HTTP stream for '{tool_call_template.name}': {e}") raise + finally: + if response and not response.closed: + response.close() + if session and not session.closed: + await session.close() async def _process_http_stream(self, response: ClientResponse, chunk_size: Optional[int], provider_name: str) -> AsyncIterator[Any]: """Process the HTTP stream and yield chunks based on content type.""" @@ -307,11 +296,8 @@ async def _process_http_stream(self, response: ClientResponse, chunk_size: Optio logger.error(f"Error processing HTTP stream for '{provider_name}': {e}") raise finally: - # The session is closed later by deregister_tool_provider or close() - if provider_name in self._active_connections: - response, _ = self._active_connections[provider_name] - if not response.closed: - response.close() + # The response and session are managed by the `call_tool_streaming` method. + pass async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: """Handles OAuth2 client credentials flow, trying both body and auth header methods.""" @@ -367,7 +353,8 @@ def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, An for param_name in path_params: if param_name in tool_args: # Replace the parameter in the URL - param_value = str(tool_args[param_name]) + # URL-encode the parameter value to prevent path injection + param_value = quote(str(tool_args[param_name])) url = url.replace(f'{{{param_name}}}', param_value) # Remove the parameter from arguments so it's not used as a query parameter tool_args.pop(param_name) diff --git a/plugins/communication_protocols/http/tests/test_sse_communication_protocol.py b/plugins/communication_protocols/http/tests/test_sse_communication_protocol.py index 2c4a2eb..aa295c7 100644 --- a/plugins/communication_protocols/http/tests/test_sse_communication_protocol.py +++ b/plugins/communication_protocols/http/tests/test_sse_communication_protocol.py @@ -107,12 +107,12 @@ async def error_handler(request): # --- Pytest Fixtures --- -@pytest.fixture -def sse_transport(): +@pytest_asyncio.fixture +async def sse_transport(): """Fixture to create and properly tear down an SseCommunicationProtocol instance.""" transport = SseCommunicationProtocol() yield transport - asyncio.run(transport.close()) + await transport.close() @pytest.fixture def app(): diff --git a/plugins/communication_protocols/text/pyproject.toml b/plugins/communication_protocols/text/pyproject.toml index d5b3993..6098f98 100644 --- a/plugins/communication_protocols/text/pyproject.toml +++ b/plugins/communication_protocols/text/pyproject.toml @@ -15,7 +15,8 @@ dependencies = [ "pydantic>=2.0", "pyyaml>=6.0", "utcp>=1.0", - "utcp-http>=1.0" + "utcp-http>=1.0", + "aiofiles>=23.2.1" ] classifiers = [ "Development Status :: 4 - Beta", 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 2db1b49..2ef89e9 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 @@ -6,6 +6,7 @@ """ import json import yaml +import aiofiles from pathlib import Path from typing import Dict, Any, Optional, AsyncGenerator, TYPE_CHECKING @@ -48,8 +49,8 @@ async def register_manual(self, caller: 'UtcpClient', manual_call_template: Call if not file_path.exists(): raise FileNotFoundError(f"Manual file not found: {file_path}") - with open(file_path, "r", encoding="utf-8") as f: - file_content = f.read() + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + file_content = await f.read() # Parse based on extension data: Any @@ -108,12 +109,13 @@ async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[ self._log_info(f"Reading content from '{file_path}' for tool '{tool_name}'") - if not file_path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") - - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - return content + try: + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + content = await f.read() + return content + except FileNotFoundError: + self._log_error(f"File not found for tool '{tool_name}': {file_path}") + raise async def call_tool_streaming(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]: """Streaming variant: yields the full content as a single chunk.""" From a66801b5c22ea7a9ffff3193f41f2b81dcd50452 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:52:03 +0200 Subject: [PATCH 2/4] 1.0.1 --- README.md | 4 ++-- core/MANIFEST.in | 1 + core/pyproject.toml | 4 ++-- .../implementations/utcp_client_implementation.py | 2 +- core/src/utcp/python_specific_tooling/version.py | 2 +- plugins/communication_protocols/cli/MANIFEST.in | 1 + plugins/communication_protocols/cli/pyproject.toml | 6 +++--- .../cli/src/utcp_cli/cli_communication_protocol.py | 14 +++++++------- .../cli/tests/test_cli_communication_protocol.py | 2 +- plugins/communication_protocols/gql/MANIFEST.in | 1 + plugins/communication_protocols/gql/pyproject.toml | 6 +++--- plugins/communication_protocols/http/MANIFEST.in | 1 + .../communication_protocols/http/pyproject.toml | 6 +++--- .../src/utcp_http/http_communication_protocol.py | 6 +++--- .../src/utcp_http/sse_communication_protocol.py | 2 +- .../streamable_http_communication_protocol.py | 6 +++--- .../http/tests/sample_tools.json | 2 +- plugins/communication_protocols/mcp/MANIFEST.in | 1 + plugins/communication_protocols/mcp/pyproject.toml | 6 +++--- plugins/communication_protocols/socket/MANIFEST.in | 1 + .../communication_protocols/socket/pyproject.toml | 6 +++--- plugins/communication_protocols/text/MANIFEST.in | 1 + .../communication_protocols/text/pyproject.toml | 6 +++--- 23 files changed, 47 insertions(+), 40 deletions(-) create mode 100644 core/MANIFEST.in create mode 100644 plugins/communication_protocols/cli/MANIFEST.in create mode 100644 plugins/communication_protocols/gql/MANIFEST.in create mode 100644 plugins/communication_protocols/http/MANIFEST.in create mode 100644 plugins/communication_protocols/mcp/MANIFEST.in create mode 100644 plugins/communication_protocols/socket/MANIFEST.in create mode 100644 plugins/communication_protocols/text/MANIFEST.in diff --git a/README.md b/README.md index 43465ca..3eee92e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Universal Tool Calling Protocol (UTCP) 1.0.0 +# Universal Tool Calling Protocol (UTCP) 1.0.1 [![Follow Org](https://img.shields.io/github/followers/universal-tool-calling-protocol?label=Follow%20Org&logo=github)](https://github.com/universal-tool-calling-protocol) [![PyPI Downloads](https://static.pepy.tech/badge/utcp)](https://pepy.tech/projects/utcp) @@ -269,7 +269,7 @@ app = FastAPI() def utcp_discovery(): return { "manual_version": "1.0.0", - "utcp_version": "1.0.0", + "utcp_version": "1.0.1", "tools": [ { "name": "get_weather", diff --git a/core/MANIFEST.in b/core/MANIFEST.in new file mode 100644 index 0000000..15f640c --- /dev/null +++ b/core/MANIFEST.in @@ -0,0 +1 @@ +include ../README.md \ No newline at end of file diff --git a/core/pyproject.toml b/core/pyproject.toml index 387af5a..205c3de 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "utcp" -version = "1.0.0" +version = "1.0.1" authors = [ { name = "UTCP Contributors" }, ] description = "Universal Tool Calling Protocol (UTCP) client library for Python" -readme = "README.md" +readme = "../README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/core/src/utcp/implementations/utcp_client_implementation.py b/core/src/utcp/implementations/utcp_client_implementation.py index 209904a..6a8b144 100644 --- a/core/src/utcp/implementations/utcp_client_implementation.py +++ b/core/src/utcp/implementations/utcp_client_implementation.py @@ -113,7 +113,7 @@ async def try_register_manual(manual_call_template=manual_call_template): logger.error(f"Error registering manual '{manual_call_template.name}': {traceback.format_exc()}") return RegisterManualResult( manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), success=False, errors=[traceback.format_exc()] ) diff --git a/core/src/utcp/python_specific_tooling/version.py b/core/src/utcp/python_specific_tooling/version.py index 11fa081..2ebf326 100644 --- a/core/src/utcp/python_specific_tooling/version.py +++ b/core/src/utcp/python_specific_tooling/version.py @@ -5,7 +5,7 @@ logger = logging.getLogger(__name__) -__version__ = "1.0.0" +__version__ = "1.0.1" try: __version__ = version("utcp") except PackageNotFoundError: diff --git a/plugins/communication_protocols/cli/MANIFEST.in b/plugins/communication_protocols/cli/MANIFEST.in new file mode 100644 index 0000000..1da0caa --- /dev/null +++ b/plugins/communication_protocols/cli/MANIFEST.in @@ -0,0 +1 @@ +include ../../../README.md diff --git a/plugins/communication_protocols/cli/pyproject.toml b/plugins/communication_protocols/cli/pyproject.toml index deb4ad6..5e9454f 100644 --- a/plugins/communication_protocols/cli/pyproject.toml +++ b/plugins/communication_protocols/cli/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-cli" -version = "1.0.0" +version = "1.0.1" authors = [ { name = "UTCP Contributors" }, ] -description = "Universal Tool Calling Protocol (UTCP) client library for Python" -readme = "README.md" +description = "UTCP communication protocol plugin for wrapping local command-line tools." +readme = "../../../README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py index 48793e7..b0293d0 100644 --- a/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py +++ b/plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py @@ -193,7 +193,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[ f"No output from discovery command for CLI provider '{manual_call_template.name}'" ], @@ -212,7 +212,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[error_msg], ) @@ -232,7 +232,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[error_msg], ) @@ -285,12 +285,12 @@ def _extract_utcp_manual_from_output(self, output: str, provider_name: str) -> O # Fallback: try to parse tools from possibly-legacy structure tools = self._parse_tool_data(data, provider_name) if tools: - return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=tools) + return UtcpManual(manual_version="0.0.0", tools=tools) return None # Fallback: try to parse as tools tools = self._parse_tool_data(data, provider_name) if tools: - return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=tools) + return UtcpManual(manual_version="0.0.0", tools=tools) except json.JSONDecodeError: pass @@ -313,7 +313,7 @@ def _extract_utcp_manual_from_output(self, output: str, provider_name: str) -> O # Fallback: try to parse tools from possibly-legacy structure tools = self._parse_tool_data(data, provider_name) if tools: - return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=tools) + return UtcpManual(manual_version="0.0.0", tools=tools) return None found_tools = self._parse_tool_data(data, provider_name) aggregated_tools.extend(found_tools) @@ -321,7 +321,7 @@ def _extract_utcp_manual_from_output(self, output: str, provider_name: str) -> O continue if aggregated_tools: - return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=aggregated_tools) + return UtcpManual(manual_version="0.0.0", tools=aggregated_tools) return None diff --git a/plugins/communication_protocols/cli/tests/test_cli_communication_protocol.py b/plugins/communication_protocols/cli/tests/test_cli_communication_protocol.py index 61b665c..fe95928 100644 --- a/plugins/communication_protocols/cli/tests/test_cli_communication_protocol.py +++ b/plugins/communication_protocols/cli/tests/test_cli_communication_protocol.py @@ -39,7 +39,7 @@ def main(): if len(sys.argv) == 1: # Return UTCP manual tools_data = { - "version": "1.0.0", + "manual_version": "1.0.0", "name": "Mock CLI Tools", "description": "Mock CLI tools for testing", "tools": [ diff --git a/plugins/communication_protocols/gql/MANIFEST.in b/plugins/communication_protocols/gql/MANIFEST.in new file mode 100644 index 0000000..1da0caa --- /dev/null +++ b/plugins/communication_protocols/gql/MANIFEST.in @@ -0,0 +1 @@ +include ../../../README.md diff --git a/plugins/communication_protocols/gql/pyproject.toml b/plugins/communication_protocols/gql/pyproject.toml index 070e945..bcd1414 100644 --- a/plugins/communication_protocols/gql/pyproject.toml +++ b/plugins/communication_protocols/gql/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-gql" -version = "1.0.0" +version = "1.0.1" authors = [ { name = "UTCP Contributors" }, ] -description = "Universal Tool Calling Protocol (UTCP) client library for Python" -readme = "README.md" +description = "UTCP communication protocol plugin for GraphQL. (Work in progress)" +readme = "../../../README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/http/MANIFEST.in b/plugins/communication_protocols/http/MANIFEST.in new file mode 100644 index 0000000..1da0caa --- /dev/null +++ b/plugins/communication_protocols/http/MANIFEST.in @@ -0,0 +1 @@ +include ../../../README.md diff --git a/plugins/communication_protocols/http/pyproject.toml b/plugins/communication_protocols/http/pyproject.toml index 97f332f..d55a8b1 100644 --- a/plugins/communication_protocols/http/pyproject.toml +++ b/plugins/communication_protocols/http/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-http" -version = "1.0.0" +version = "1.0.1" authors = [ { name = "UTCP Contributors" }, ] -description = "Universal Tool Calling Protocol (UTCP) client library for Python" -readme = "README.md" +description = "UTCP communication protocol plugin for HTTP, SSE, and streamable HTTP, plus an OpenAPI converter." +readme = "../../../README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", 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 ef7350a..e901c35 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 @@ -204,7 +204,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[error_msg] ) except (json.JSONDecodeError, yaml.YAMLError) as e: @@ -213,7 +213,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[error_msg] ) except Exception as e: @@ -222,7 +222,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[error_msg] ) diff --git a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py index 17eae7a..e0fa7d7 100644 --- a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py @@ -140,7 +140,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[traceback.format_exc()] ) diff --git a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py index c81c548..8c1c6c3 100644 --- a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py @@ -142,7 +142,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[error_msg] ) except (json.JSONDecodeError, aiohttp.ClientError) as e: @@ -151,7 +151,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[error_msg] ) except Exception as e: @@ -160,7 +160,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R return RegisterManualResult( success=False, manual_call_template=manual_call_template, - manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]), + manual=UtcpManual(manual_version="0.0.0", tools=[]), errors=[error_msg] ) diff --git a/plugins/communication_protocols/http/tests/sample_tools.json b/plugins/communication_protocols/http/tests/sample_tools.json index 18fe1b6..ef0d7f9 100644 --- a/plugins/communication_protocols/http/tests/sample_tools.json +++ b/plugins/communication_protocols/http/tests/sample_tools.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "manual_version": "1.0.0", "name": "Sample Tool Collection", "description": "A collection of sample tools for testing the text transport", "tools": [ diff --git a/plugins/communication_protocols/mcp/MANIFEST.in b/plugins/communication_protocols/mcp/MANIFEST.in new file mode 100644 index 0000000..1da0caa --- /dev/null +++ b/plugins/communication_protocols/mcp/MANIFEST.in @@ -0,0 +1 @@ +include ../../../README.md diff --git a/plugins/communication_protocols/mcp/pyproject.toml b/plugins/communication_protocols/mcp/pyproject.toml index 8d243f7..4ae1d62 100644 --- a/plugins/communication_protocols/mcp/pyproject.toml +++ b/plugins/communication_protocols/mcp/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-mcp" -version = "1.0.0" +version = "1.0.1" authors = [ { name = "UTCP Contributors" }, ] -description = "Universal Tool Calling Protocol (UTCP) client library for Python" -readme = "README.md" +description = "UTCP communication protocol plugin for interoperability with the Model Context Protocol (MCP)." +readme = "../../../README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/socket/MANIFEST.in b/plugins/communication_protocols/socket/MANIFEST.in new file mode 100644 index 0000000..1da0caa --- /dev/null +++ b/plugins/communication_protocols/socket/MANIFEST.in @@ -0,0 +1 @@ +include ../../../README.md diff --git a/plugins/communication_protocols/socket/pyproject.toml b/plugins/communication_protocols/socket/pyproject.toml index fde3bd6..7df4988 100644 --- a/plugins/communication_protocols/socket/pyproject.toml +++ b/plugins/communication_protocols/socket/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-socket" -version = "1.0.0" +version = "1.0.1" authors = [ { name = "UTCP Contributors" }, ] -description = "Universal Tool Calling Protocol (UTCP) client library for Python" -readme = "README.md" +description = "UTCP communication protocol plugin for TCP and UDP protocols. (Work in progress)" +readme = "../../../README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/text/MANIFEST.in b/plugins/communication_protocols/text/MANIFEST.in new file mode 100644 index 0000000..1da0caa --- /dev/null +++ b/plugins/communication_protocols/text/MANIFEST.in @@ -0,0 +1 @@ +include ../../../README.md diff --git a/plugins/communication_protocols/text/pyproject.toml b/plugins/communication_protocols/text/pyproject.toml index 6098f98..11b435a 100644 --- a/plugins/communication_protocols/text/pyproject.toml +++ b/plugins/communication_protocols/text/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "utcp-text" -version = "1.0.0" +version = "1.0.1" authors = [ { name = "UTCP Contributors" }, ] -description = "Universal Tool Calling Protocol (UTCP) client library for Python" -readme = "README.md" +description = "UTCP communication protocol plugin for reading text files." +readme = "../../../README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", From 1bd9399bea4639d93b93f9d2057463976ad09e8f Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:59:09 +0200 Subject: [PATCH 3/4] make a readme copy --- core/MANIFEST.in | 1 - core/README.md | 494 ++++++++++++++++++ core/pyproject.toml | 2 +- .../communication_protocols/cli/MANIFEST.in | 1 - plugins/communication_protocols/cli/README.md | 1 + .../cli/pyproject.toml | 2 +- .../communication_protocols/gql/MANIFEST.in | 1 - plugins/communication_protocols/gql/README.md | 1 + .../gql/pyproject.toml | 2 +- .../communication_protocols/http/MANIFEST.in | 1 - .../communication_protocols/http/README.md | 1 + .../http/pyproject.toml | 2 +- .../communication_protocols/mcp/MANIFEST.in | 1 - plugins/communication_protocols/mcp/README.md | 1 + .../mcp/pyproject.toml | 2 +- .../socket/MANIFEST.in | 1 - .../communication_protocols/socket/README.md | 1 + .../socket/pyproject.toml | 2 +- .../communication_protocols/text/MANIFEST.in | 1 - .../communication_protocols/text/README.md | 1 + .../text/pyproject.toml | 2 +- 21 files changed, 507 insertions(+), 14 deletions(-) delete mode 100644 core/MANIFEST.in create mode 100644 core/README.md delete mode 100644 plugins/communication_protocols/cli/MANIFEST.in create mode 100644 plugins/communication_protocols/cli/README.md delete mode 100644 plugins/communication_protocols/gql/MANIFEST.in create mode 100644 plugins/communication_protocols/gql/README.md delete mode 100644 plugins/communication_protocols/http/MANIFEST.in create mode 100644 plugins/communication_protocols/http/README.md delete mode 100644 plugins/communication_protocols/mcp/MANIFEST.in create mode 100644 plugins/communication_protocols/mcp/README.md delete mode 100644 plugins/communication_protocols/socket/MANIFEST.in create mode 100644 plugins/communication_protocols/socket/README.md delete mode 100644 plugins/communication_protocols/text/MANIFEST.in create mode 100644 plugins/communication_protocols/text/README.md diff --git a/core/MANIFEST.in b/core/MANIFEST.in deleted file mode 100644 index 15f640c..0000000 --- a/core/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include ../README.md \ No newline at end of file diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..3eee92e --- /dev/null +++ b/core/README.md @@ -0,0 +1,494 @@ +# Universal Tool Calling Protocol (UTCP) 1.0.1 + +[![Follow Org](https://img.shields.io/github/followers/universal-tool-calling-protocol?label=Follow%20Org&logo=github)](https://github.com/universal-tool-calling-protocol) +[![PyPI Downloads](https://static.pepy.tech/badge/utcp)](https://pepy.tech/projects/utcp) +[![License](https://img.shields.io/github/license/universal-tool-calling-protocol/python-utcp)](https://github.com/universal-tool-calling-protocol/python-utcp/blob/main/LICENSE) +[![CDTM S23](https://img.shields.io/badge/CDTM-S23-0b84f3)](https://cdtm.com/) + +## Introduction + +The Universal Tool Calling Protocol (UTCP) is a modern, flexible, and scalable standard for defining and interacting with tools across a wide variety of communication protocols. UTCP 1.0.0 introduces a modular core with a plugin-based architecture, making it more extensible, testable, and easier to package. + +In contrast to other protocols, UTCP places a strong emphasis on: + +* **Scalability**: UTCP is designed to handle a large number of tools and providers without compromising performance. +* **Extensibility**: A pluggable architecture allows developers to easily add new communication protocols, tool storage mechanisms, and search strategies without modifying the core library. +* **Interoperability**: With a growing ecosystem of protocol plugins (including HTTP, SSE, CLI, and more), UTCP can integrate with almost any existing service or infrastructure. +* **Ease of Use**: The protocol is built on simple, well-defined Pydantic models, making it easy for developers to implement and use. + + +![MCP vs. UTCP](https://github.com/user-attachments/assets/3cadfc19-8eea-4467-b606-66e580b89444) + +## New Architecture in 1.0.0 + +UTCP has been refactored into a core library and a set of optional plugins. + +### Core Package (`utcp`) + +The `utcp` package provides the central components and interfaces: +* **Data Models**: Pydantic models for `Tool`, `CallTemplate`, `UtcpManual`, and `Auth`. +* **Pluggable Interfaces**: + * `CommunicationProtocol`: Defines the contract for protocol-specific communication (e.g., HTTP, CLI). + * `ConcurrentToolRepository`: An interface for storing and retrieving tools with thread-safe access. + * `ToolSearchStrategy`: An interface for implementing tool search algorithms. + * `VariableSubstitutor`: Handles variable substitution in configurations. + * `ToolPostProcessor`: Allows for modifying tool results before they are returned. +* **Default Implementations**: + * `UtcpClient`: The main client for interacting with the UTCP ecosystem. + * `InMemToolRepository`: An in-memory tool repository with asynchronous read-write locks. + * `TagAndDescriptionWordMatchStrategy`: An improved search strategy that matches on tags and description keywords. + +### Protocol Plugins + +Communication protocols are now separate, installable packages. This keeps the core lean and allows users to install only the protocols they need. +* `utcp-http`: Supports HTTP, SSE, and streamable HTTP, plus an OpenAPI converter. +* `utcp-cli`: For wrapping local command-line tools. +* `utcp-mcp`: For interoperability with the Model Context Protocol (MCP). +* `utcp-text`: For reading text files. +* `utcp-socket`: Scaffolding for TCP and UDP protocols. (Work in progress, requires update) +* `utcp-gql`: Scaffolding for GraphQL. (Work in progress, requires update) + +## Installation + +Install the core library and any required protocol plugins. + +```bash +# Install the core client and the HTTP plugin +pip install utcp utcp-http + +# Install the CLI plugin as well +pip install utcp-cli +``` + +For development, you can install the packages in editable mode from the cloned repository: + +```bash +# Clone the repository +git clone https://github.com/universal-tool-calling-protocol/python-utcp.git +cd python-utcp + +# Install the core package in editable mode with dev dependencies +pip install -e core[dev] + +# Install a specific protocol plugin in editable mode +pip install -e plugins/communication_protocols/http +``` + +## Migration Guide from 0.x to 1.0.0 + +Version 1.0.0 introduces several breaking changes. Follow these steps to migrate your project. + +1. **Update Dependencies**: Install the new `utcp` core package and the specific protocol plugins you use (e.g., `utcp-http`, `utcp-cli`). +2. **Configuration**: + * **Configuration Object**: `UtcpClient` is initialized with a `UtcpClientConfig` object, dict or a path to a JSON file containing the configuration. + * **Manual Call Templates**: The `providers_file_path` option is removed. Instead of a file path, you now provide a list of `manual_call_templates` directly within the `UtcpClientConfig`. + * **Terminology**: The term `provider` has been replaced with `call_template`, and `provider_type` is now `call_template_type`. + * **Streamable HTTP**: The `call_template_type` `http_stream` has been renamed to `streamable_http`. +3. **Update Imports**: Change your imports to reflect the new modular structure. For example, `from utcp.client.transport_interfaces.http_transport import HttpProvider` becomes `from utcp_http.http_call_template import HttpCallTemplate`. +4. **Tool Search**: If you were using the default search, the new strategy is `TagAndDescriptionWordMatchStrategy`. This is the new default and requires no changes unless you were implementing a custom strategy. +5. **Tool Naming**: Tool names are now namespaced as `manual_name.tool_name`. The client handles this automatically. +6 **Variable Substitution Namespacing**: Variables that are subsituted in different `call_templates`, are first namespaced with the name of the manual with the `_` duplicated. So a key in a tool call template called `API_KEY` from the manual `manual_1` would be converted to `manual__1_API_KEY`. + +## Usage Examples + +### 1. Using the UTCP Client + +**`config.json`** (Optional) + +You can define a comprehensive client configuration in a JSON file. All of these fields are optional. + +```json +{ + "variables": { + "openlibrary_URL": "https://openlibrary.org/static/openapi.json" + }, + "load_variables_from": [ + { + "variable_loader_type": "dotenv", + "env_file_path": ".env" + } + ], + "tool_repository": { + "tool_repository_type": "in_memory" + }, + "tool_search_strategy": { + "tool_search_strategy_type": "tag_and_description_word_match" + }, + "manual_call_templates": [ + { + "name": "openlibrary", + "call_template_type": "http", + "http_method": "GET", + "url": "${URL}", + "content_type": "application/json" + }, + ], + "post_processing": [ + { + "tool_post_processor_type": "filter_dict", + "only_include_keys": ["name", "key"], + "only_include_tools": ["openlibrary.read_search_authors_json_search_authors_json_get"] + } + ] +} +``` + +**`client.py`** + +```python +import asyncio +from utcp.utcp_client import UtcpClient +from utcp.data.utcp_client_config import UtcpClientConfig + +async def main(): + # The UtcpClient can be created with a config file path, a dict, or a UtcpClientConfig object. + + # Option 1: Initialize from a config file path + # client_from_file = await UtcpClient.create(config="./config.json") + + # Option 2: Initialize from a dictionary + client_from_dict = await UtcpClient.create(config={ + "variables": { + "openlibrary_URL": "https://openlibrary.org/static/openapi.json" + }, + "load_variables_from": [ + { + "variable_loader_type": "dotenv", + "env_file_path": ".env" + } + ], + "tool_repository": { + "tool_repository_type": "in_memory" + }, + "tool_search_strategy": { + "tool_search_strategy_type": "tag_and_description_word_match" + }, + "manual_call_templates": [ + { + "name": "openlibrary", + "call_template_type": "http", + "http_method": "GET", + "url": "${URL}", + "content_type": "application/json" + } + ], + "post_processing": [ + { + "tool_post_processor_type": "filter_dict", + "only_include_keys": ["name", "key"], + "only_include_tools": ["openlibrary.read_search_authors_json_search_authors_json_get"] + } + ] + }) + + # Option 3: Initialize with a full-featured UtcpClientConfig object + from utcp_http.http_call_template import HttpCallTemplate + from utcp.data.variable_loader import VariableLoaderSerializer + from utcp.interfaces.tool_post_processor import ToolPostProcessorConfigSerializer + + config_obj = UtcpClientConfig( + variables={"openlibrary_URL": "https://openlibrary.org/static/openapi.json"}, + load_variables_from=[ + VariableLoaderSerializer().validate_dict({ + "variable_loader_type": "dotenv", "env_file_path": ".env" + }) + ], + manual_call_templates=[ + HttpCallTemplate( + name="openlibrary", + call_template_type="http", + http_method="GET", + url="${URL}", + content_type="application/json" + ) + ], + post_processing=[ + ToolPostProcessorConfigSerializer().validate_dict({ + "tool_post_processor_type": "filter_dict", + "only_include_keys": ["name", "key"], + "only_include_tools": ["openlibrary.read_search_authors_json_search_authors_json_get"] + }) + ] + ) + client = await UtcpClient.create(config=config_obj) + + # Call a tool. The name is namespaced: `manual_name.tool_name` + result = await client.call_tool( + tool_name="openlibrary.read_search_authors_json_search_authors_json_get", + tool_args={"q": "J. K. Rowling"} + ) + + print(result) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### 2. Providing a UTCP Manual + +A `UTCPManual` describes the tools you offer. The key change is replacing `tool_provider` with `call_template`. + +**`server.py`** + +UTCP decorator version: + +```python +from fastapi import FastAPI +from utcp_http.http_call_template import HttpCallTemplate +from utcp.data.utcp_manual import UtcpManual +from utcp.python_specific_tooling.tool_decorator import utcp_tool + +app = FastAPI() + +# The discovery endpoint returns the tool manual +@app.get("/utcp") +def utcp_discovery(): + return UtcpManual.create_from_decorators(manual_version="1.0.0") + +# The actual tool endpoint +@utcp_tool(tool_call_template=HttpCallTemplate( + name="get_weather", + url=f"https://example.com/api/weather", + http_method="GET" +), tags=["weather"]) +@app.get("/api/weather") +def get_weather(location: str): + return {"temperature": 22.5, "conditions": "Sunny"} +``` + + +No UTCP dependencies server version: + +```python +from fastapi import FastAPI + +app = FastAPI() + +# The discovery endpoint returns the tool manual +@app.get("/utcp") +def utcp_discovery(): + return { + "manual_version": "1.0.0", + "utcp_version": "1.0.1", + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a location", + "tags": ["weather"], + "inputs": { + "type": "object", + "properties": { + "location": {"type": "string"} + } + }, + "outputs": { + "type": "object", + "properties": { + "temperature": {"type": "number"}, + "conditions": {"type": "string"} + } + }, + "call_template": { + "call_template_type": "http", + "url": "https://example.com/api/weather", + "http_method": "GET" + } + } + ] + } + +# The actual tool endpoint +@app.get("/api/weather") +def get_weather(location: str): + return {"temperature": 22.5, "conditions": "Sunny"} +``` + +### 3. Full examples + +You can find full examples in the [examples repository](https://github.com/universal-tool-calling-protocol/utcp-examples). + +## Protocol Specification + +### `UtcpManual` and `Tool` Models + +The `tool_provider` object inside a `Tool` has been replaced by `call_template`. + +```json +{ + "manual_version": "string", + "utcp_version": "string", + "tools": [ + { + "name": "string", + "description": "string", + "inputs": { ... }, + "outputs": { ... }, + "tags": ["string"], + "call_template": { + "call_template_type": "http", + "url": "https://...", + "http_method": "GET" + } + } + ] +} +``` + +## Call Template Configuration Examples + +Configuration examples for each protocol. Remember to replace `provider_type` with `call_template_type`. + +### HTTP Call Template + +```json +{ + "name": "my_rest_api", + "call_template_type": "http", // Required + "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_type": "api_key", + "api_key": "Bearer $API_KEY", // Required + "var_name": "Authorization", // Optional, default: "X-Api-Key" + "location": "header" // Optional, default: "header" + }, + "headers": { // Optional + "X-Custom-Header": "value" + }, + "body_field": "body", // Optional, default: "body" + "header_fields": ["user_id"] // Optional +} +``` + +### SSE (Server-Sent Events) Call Template + +```json +{ + "name": "my_sse_stream", + "call_template_type": "sse", // Required + "url": "https://api.example.com/events", // Required + "event_type": "message", // Optional + "reconnect": true, // Optional, default: true + "retry_timeout": 30000, // Optional, default: 30000 (ms) + "auth": { // Optional, example using BasicAuth + "auth_type": "basic", + "username": "${USERNAME}", // Required + "password": "${PASSWORD}" // Required + }, + "headers": { // Optional + "X-Client-ID": "12345" + }, + "body_field": null, // Optional + "header_fields": [] // Optional +} +``` + +### Streamable HTTP Call Template + +Note the name change from `http_stream` to `streamable_http`. + +```json +{ + "name": "streaming_data_source", + "call_template_type": "streamable_http", // Required + "url": "https://api.example.com/stream", // Required + "http_method": "POST", // Optional, default: "GET" + "content_type": "application/octet-stream", // Optional, default: "application/octet-stream" + "chunk_size": 4096, // Optional, default: 4096 + "timeout": 60000, // Optional, default: 60000 (ms) + "auth": null, // Optional + "headers": {}, // Optional + "body_field": "data", // Optional + "header_fields": [] // Optional +} +``` + +### CLI Call Template + +```json +{ + "name": "my_cli_tool", + "call_template_type": "cli", // Required + "command_name": "my-command --utcp", // Required + "env_vars": { // Optional + "MY_VAR": "my_value" + }, + "working_dir": "/path/to/working/directory", // Optional + "auth": null // Optional (always null for CLI) +} +``` + +### Text Call Template + +```json +{ + "name": "my_text_manual", + "call_template_type": "text", // Required + "file_path": "./manuals/my_manual.json", // Required + "auth": null // Optional (always null for Text) +} +``` + +### MCP (Model Context Protocol) Call Template + +```json +{ + "name": "my_mcp_server", + "call_template_type": "mcp", // Required + "config": { // Required + "mcpServers": { + "server_name": { + "transport": "stdio", + "command": ["python", "-m", "my_mcp_server"] + } + } + }, + "auth": { // Optional, example using OAuth2 + "auth_type": "oauth2", + "token_url": "https://auth.example.com/token", // Required + "client_id": "${CLIENT_ID}", // Required + "client_secret": "${CLIENT_SECRET}", // Required + "scope": "read:tools" // Optional + } +} +``` + +## Testing + +The testing structure has been updated to reflect the new core/plugin split. + +### Running Tests + +To run all tests for the core library and all plugins: +```bash +# Ensure you have installed all dev dependencies +python -m pytest +``` + +To run tests for a specific package (e.g., the core library): +```bash +python -m pytest core/tests/ +``` + +To run tests for a specific plugin (e.g., HTTP): +```bash +python -m pytest plugins/communication_protocols/http/tests/ -v +``` + +To run tests with coverage: +```bash +python -m pytest --cov=utcp --cov-report=xml +``` + +## Build + +The build process now involves building each package (`core` and `plugins`) separately if needed, though they are published to PyPI independently. + +1. Create and activate a virtual environment. +2. Install build dependencies: `pip install build`. +3. Navigate to the package directory (e.g., `cd core`). +4. Run the build: `python -m build`. +5. The distributable files (`.whl` and `.tar.gz`) will be in the `dist/` directory. + +## [Contributors](https://www.utcp.io/about) diff --git a/core/pyproject.toml b/core/pyproject.toml index 205c3de..aa17d4d 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -9,7 +9,7 @@ authors = [ { name = "UTCP Contributors" }, ] description = "Universal Tool Calling Protocol (UTCP) client library for Python" -readme = "../README.md" +readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/cli/MANIFEST.in b/plugins/communication_protocols/cli/MANIFEST.in deleted file mode 100644 index 1da0caa..0000000 --- a/plugins/communication_protocols/cli/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include ../../../README.md diff --git a/plugins/communication_protocols/cli/README.md b/plugins/communication_protocols/cli/README.md new file mode 100644 index 0000000..8febb5a --- /dev/null +++ b/plugins/communication_protocols/cli/README.md @@ -0,0 +1 @@ +Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file diff --git a/plugins/communication_protocols/cli/pyproject.toml b/plugins/communication_protocols/cli/pyproject.toml index 5e9454f..ff37fd5 100644 --- a/plugins/communication_protocols/cli/pyproject.toml +++ b/plugins/communication_protocols/cli/pyproject.toml @@ -9,7 +9,7 @@ authors = [ { name = "UTCP Contributors" }, ] description = "UTCP communication protocol plugin for wrapping local command-line tools." -readme = "../../../README.md" +readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/gql/MANIFEST.in b/plugins/communication_protocols/gql/MANIFEST.in deleted file mode 100644 index 1da0caa..0000000 --- a/plugins/communication_protocols/gql/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include ../../../README.md diff --git a/plugins/communication_protocols/gql/README.md b/plugins/communication_protocols/gql/README.md new file mode 100644 index 0000000..8febb5a --- /dev/null +++ b/plugins/communication_protocols/gql/README.md @@ -0,0 +1 @@ +Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file diff --git a/plugins/communication_protocols/gql/pyproject.toml b/plugins/communication_protocols/gql/pyproject.toml index bcd1414..1baa897 100644 --- a/plugins/communication_protocols/gql/pyproject.toml +++ b/plugins/communication_protocols/gql/pyproject.toml @@ -9,7 +9,7 @@ authors = [ { name = "UTCP Contributors" }, ] description = "UTCP communication protocol plugin for GraphQL. (Work in progress)" -readme = "../../../README.md" +readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/http/MANIFEST.in b/plugins/communication_protocols/http/MANIFEST.in deleted file mode 100644 index 1da0caa..0000000 --- a/plugins/communication_protocols/http/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include ../../../README.md diff --git a/plugins/communication_protocols/http/README.md b/plugins/communication_protocols/http/README.md new file mode 100644 index 0000000..8febb5a --- /dev/null +++ b/plugins/communication_protocols/http/README.md @@ -0,0 +1 @@ +Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file diff --git a/plugins/communication_protocols/http/pyproject.toml b/plugins/communication_protocols/http/pyproject.toml index d55a8b1..c7f5064 100644 --- a/plugins/communication_protocols/http/pyproject.toml +++ b/plugins/communication_protocols/http/pyproject.toml @@ -9,7 +9,7 @@ authors = [ { name = "UTCP Contributors" }, ] description = "UTCP communication protocol plugin for HTTP, SSE, and streamable HTTP, plus an OpenAPI converter." -readme = "../../../README.md" +readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/mcp/MANIFEST.in b/plugins/communication_protocols/mcp/MANIFEST.in deleted file mode 100644 index 1da0caa..0000000 --- a/plugins/communication_protocols/mcp/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include ../../../README.md diff --git a/plugins/communication_protocols/mcp/README.md b/plugins/communication_protocols/mcp/README.md new file mode 100644 index 0000000..8febb5a --- /dev/null +++ b/plugins/communication_protocols/mcp/README.md @@ -0,0 +1 @@ +Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file diff --git a/plugins/communication_protocols/mcp/pyproject.toml b/plugins/communication_protocols/mcp/pyproject.toml index 4ae1d62..77f0c44 100644 --- a/plugins/communication_protocols/mcp/pyproject.toml +++ b/plugins/communication_protocols/mcp/pyproject.toml @@ -9,7 +9,7 @@ authors = [ { name = "UTCP Contributors" }, ] description = "UTCP communication protocol plugin for interoperability with the Model Context Protocol (MCP)." -readme = "../../../README.md" +readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/socket/MANIFEST.in b/plugins/communication_protocols/socket/MANIFEST.in deleted file mode 100644 index 1da0caa..0000000 --- a/plugins/communication_protocols/socket/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include ../../../README.md diff --git a/plugins/communication_protocols/socket/README.md b/plugins/communication_protocols/socket/README.md new file mode 100644 index 0000000..8febb5a --- /dev/null +++ b/plugins/communication_protocols/socket/README.md @@ -0,0 +1 @@ +Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file diff --git a/plugins/communication_protocols/socket/pyproject.toml b/plugins/communication_protocols/socket/pyproject.toml index 7df4988..ac8e507 100644 --- a/plugins/communication_protocols/socket/pyproject.toml +++ b/plugins/communication_protocols/socket/pyproject.toml @@ -9,7 +9,7 @@ authors = [ { name = "UTCP Contributors" }, ] description = "UTCP communication protocol plugin for TCP and UDP protocols. (Work in progress)" -readme = "../../../README.md" +readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", diff --git a/plugins/communication_protocols/text/MANIFEST.in b/plugins/communication_protocols/text/MANIFEST.in deleted file mode 100644 index 1da0caa..0000000 --- a/plugins/communication_protocols/text/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include ../../../README.md diff --git a/plugins/communication_protocols/text/README.md b/plugins/communication_protocols/text/README.md new file mode 100644 index 0000000..8febb5a --- /dev/null +++ b/plugins/communication_protocols/text/README.md @@ -0,0 +1 @@ +Find the UTCP readme at https://github.com/universal-tool-calling-protocol/python-utcp. \ No newline at end of file diff --git a/plugins/communication_protocols/text/pyproject.toml b/plugins/communication_protocols/text/pyproject.toml index 11b435a..181d0e5 100644 --- a/plugins/communication_protocols/text/pyproject.toml +++ b/plugins/communication_protocols/text/pyproject.toml @@ -9,7 +9,7 @@ authors = [ { name = "UTCP Contributors" }, ] description = "UTCP communication protocol plugin for reading text files." -readme = "../../../README.md" +readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.0", From fb577b785cd44fc35ba799f900b39e1bbae1c0a7 Mon Sep 17 00:00:00 2001 From: Razvan Radulescu <43811028+h3xxit@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:05:08 +0200 Subject: [PATCH 4/4] Add safe url parsing and fix openapi converter comments --- .../http/src/utcp_http/http_communication_protocol.py | 2 +- .../http/src/utcp_http/openapi_converter.py | 5 ++--- .../http/src/utcp_http/sse_communication_protocol.py | 2 +- .../src/utcp_http/streamable_http_communication_protocol.py | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) 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 e901c35..94a3b4b 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 @@ -396,7 +396,7 @@ def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, An if param_name in tool_args: # Replace the parameter in the URL # URL-encode the parameter value to prevent path injection - param_value = quote(str(tool_args[param_name])) + param_value = quote(str(tool_args[param_name]), safe="") url = url.replace(f'{{{param_name}}}', param_value) # Remove the parameter from arguments so it's not used as a query parameter tool_args.pop(param_name) 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 82a3786..676ddf8 100644 --- a/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py +++ b/plugins/communication_protocols/http/src/utcp_http/openapi_converter.py @@ -65,14 +65,13 @@ def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None, openapi_spec: Parsed OpenAPI specification as a dictionary. spec_url: Optional URL where the specification was retrieved from. Used for base URL determination if servers are not specified. - call_template_name: Optional custom name for the call_template. If not - provided, derives name from the specification title. + call_template_name: Optional custom name for the call_template if + the specification title is not provided. """ self.spec = openapi_spec self.spec_url = spec_url # Single counter for all placeholder variables self.placeholder_counter = 0 - # If call_template_name is None then get the first word in spec.info.title if call_template_name is None: call_template_name = "openapi_call_template_" + uuid.uuid4().hex title = openapi_spec.get("info", {}).get("title", call_template_name) diff --git a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py index e0fa7d7..6768875 100644 --- a/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py @@ -329,7 +329,7 @@ def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, An if param_name in tool_args: # Replace the parameter in the URL # URL-encode the parameter value to prevent path injection - param_value = quote(str(tool_args[param_name])) + param_value = quote(str(tool_args[param_name]), safe="") url = url.replace(f'{{{param_name}}}', param_value) # Remove the parameter from arguments so it's not used as a query parameter tool_args.pop(param_name) diff --git a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py index 8c1c6c3..47144f8 100644 --- a/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py +++ b/plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py @@ -354,7 +354,7 @@ def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, An if param_name in tool_args: # Replace the parameter in the URL # URL-encode the parameter value to prevent path injection - param_value = quote(str(tool_args[param_name])) + param_value = quote(str(tool_args[param_name]), safe="") url = url.replace(f'{{{param_name}}}', param_value) # Remove the parameter from arguments so it's not used as a query parameter tool_args.pop(param_name)