diff --git a/README.md b/README.md index 6b520f5..400b0da 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,7 @@ Configuration examples for each protocol. Remember to replace `provider_type` wi "url": "https://api.example.com/users/{user_id}", // Required "http_method": "POST", // Required, default: "GET" "content_type": "application/json", // Optional, default: "application/json" + "allowed_communication_protocols": ["http"], // Optional, defaults to [call_template_type]. Restricts which protocols tools can use. "auth": { // Optional, authentication for the HTTP request (example using ApiKeyAuth for Bearer token) "auth_type": "api_key", "api_key": "Bearer $API_KEY", // Required @@ -514,6 +515,81 @@ Note the name change from `http_stream` to `streamable_http`. } ``` +## Security: Protocol Restrictions + +UTCP provides fine-grained control over which communication protocols each manual can use through the `allowed_communication_protocols` field. This prevents potentially dangerous protocol escalation (e.g., an HTTP-based manual accidentally calling CLI tools). + +### Default Behavior (Secure by Default) + +When `allowed_communication_protocols` is not set or is empty, a manual can only register and call tools that use the **same protocol type** as the manual itself: + +```python +from utcp_http.http_call_template import HttpCallTemplate + +# This manual can ONLY register/call HTTP tools (default restriction) +http_manual = HttpCallTemplate( + name="my_api", + call_template_type="http", + url="https://api.example.com/utcp" + # allowed_communication_protocols not set → defaults to ["http"] +) +``` + +### Allowing Multiple Protocols + +To allow a manual to work with tools from multiple protocols, explicitly set `allowed_communication_protocols`: + +```python +from utcp_http.http_call_template import HttpCallTemplate + +# This manual can register/call both HTTP and CLI tools +multi_protocol_manual = HttpCallTemplate( + name="flexible_manual", + call_template_type="http", + url="https://api.example.com/utcp", + allowed_communication_protocols=["http", "cli"] # Explicitly allow both +) +``` + +### JSON Configuration + +```json +{ + "name": "my_api", + "call_template_type": "http", + "url": "https://api.example.com/utcp", + "allowed_communication_protocols": ["http", "cli", "mcp"] +} +``` + +### Behavior Summary + +| `allowed_communication_protocols` | Manual Type | Allowed Tool Protocols | +|----------------------------------|-------------|------------------------| +| Not set / `null` | `"http"` | Only `"http"` | +| `[]` (empty) | `"http"` | Only `"http"` | +| `["http", "cli"]` | `"http"` | `"http"` and `"cli"` | +| `["http", "cli", "mcp"]` | `"cli"` | `"http"`, `"cli"`, and `"mcp"` | + +### Registration Filtering + +During `register_manual()`, tools that don't match the allowed protocols are automatically filtered out with a warning: + +``` +WARNING - Tool 'dangerous_tool' uses communication protocol 'cli' which is not in +allowed protocols ['http'] for manual 'my_api'. Tool will not be registered. +``` + +### Call-Time Validation + +Even if a tool somehow exists in the repository, calling it will fail if its protocol is not allowed: + +```python +# Raises ValueError: Tool 'my_api.some_cli_tool' uses communication protocol 'cli' +# which is not allowed by manual 'my_api'. Allowed protocols: ['http'] +await client.call_tool("my_api.some_cli_tool", {"arg": "value"}) +``` + ## Testing The testing structure has been updated to reflect the new core/plugin split. diff --git a/core/README.md b/core/README.md index 35ff09e..400b0da 100644 --- a/core/README.md +++ b/core/README.md @@ -86,6 +86,7 @@ UTCP supports multiple communication protocols through dedicated plugins: | [`utcp-cli`](plugins/communication_protocols/cli/) | Command-line tools | ✅ Stable | [CLI Plugin README](plugins/communication_protocols/cli/README.md) | | [`utcp-mcp`](plugins/communication_protocols/mcp/) | Model Context Protocol | ✅ Stable | [MCP Plugin README](plugins/communication_protocols/mcp/README.md) | | [`utcp-text`](plugins/communication_protocols/text/) | Local file-based tools | ✅ Stable | [Text Plugin README](plugins/communication_protocols/text/README.md) | +| [`utcp-websocket`](plugins/communication_protocols/websocket/) | WebSocket real-time bidirectional communication | ✅ Stable | [WebSocket Plugin README](plugins/communication_protocols/websocket/README.md) | | [`utcp-socket`](plugins/communication_protocols/socket/) | TCP/UDP protocols | 🚧 In Progress | [Socket Plugin README](plugins/communication_protocols/socket/README.md) | | [`utcp-gql`](plugins/communication_protocols/gql/) | GraphQL APIs | 🚧 In Progress | [GraphQL Plugin README](plugins/communication_protocols/gql/README.md) | @@ -376,12 +377,19 @@ Configuration examples for each protocol. Remember to replace `provider_type` wi "url": "https://api.example.com/users/{user_id}", // Required "http_method": "POST", // Required, default: "GET" "content_type": "application/json", // Optional, default: "application/json" - "auth": { // Optional, example using ApiKeyAuth for a Bearer token. The client must prepend "Bearer " to the token. + "allowed_communication_protocols": ["http"], // Optional, defaults to [call_template_type]. Restricts which protocols tools can use. + "auth": { // Optional, authentication for the HTTP request (example using ApiKeyAuth for Bearer token) "auth_type": "api_key", "api_key": "Bearer $API_KEY", // Required "var_name": "Authorization", // Optional, default: "X-Api-Key" "location": "header" // Optional, default: "header" }, + "auth_tools": { // Optional, authentication for converted tools, if this call template points to an openapi spec that should be automatically converted to a utcp manual (applied only to endpoints requiring auth per OpenAPI spec) + "auth_type": "api_key", + "api_key": "Bearer $TOOL_API_KEY", // Required + "var_name": "Authorization", // Optional, default: "X-Api-Key" + "location": "header" // Optional, default: "header" + }, "headers": { // Optional "X-Custom-Header": "value" }, @@ -437,31 +445,34 @@ Note the name change from `http_stream` to `streamable_http`. ```json { - "name": "my_cli_tool", + "name": "multi_step_cli_tool", "call_template_type": "cli", // Required - "commands": [ // Required - array of commands to execute in sequence + "commands": [ // Required - sequential command execution { - "command": "cd UTCP_ARG_target_dir_UTCP_END", - "append_to_final_output": false // Optional, default is false if not last command + "command": "git clone UTCP_ARG_repo_url_UTCP_END temp_repo", + "append_to_final_output": false }, { - "command": "my-command --input UTCP_ARG_input_file_UTCP_END" - // append_to_final_output defaults to true for last command + "command": "cd temp_repo && find . -name '*.py' | wc -l" + // Last command output returned by default } ], "env_vars": { // Optional - "MY_VAR": "my_value" + "GIT_AUTHOR_NAME": "UTCP Bot", + "API_KEY": "${MY_API_KEY}" }, - "working_dir": "/path/to/working/directory", // Optional + "working_dir": "/tmp", // Optional "auth": null // Optional (always null for CLI) } ``` -**Notes:** -- Commands execute in a single subprocess (PowerShell on Windows, Bash on Unix) -- Use `UTCP_ARG_argname_UTCP_END` placeholders for arguments -- Reference previous command output with `$CMD_0_OUTPUT`, `$CMD_1_OUTPUT`, etc. -- Only the last command's output is returned by default +**CLI Protocol Features:** +- **Multi-command execution**: Commands run sequentially in single subprocess +- **Cross-platform**: PowerShell on Windows, Bash on Unix/Linux/macOS +- **State preservation**: Directory changes (`cd`) persist between commands +- **Argument placeholders**: `UTCP_ARG_argname_UTCP_END` format +- **Output referencing**: Access previous outputs with `$CMD_0_OUTPUT`, `$CMD_1_OUTPUT` +- **Flexible output control**: Choose which command outputs to include in final result ### Text Call Template @@ -470,7 +481,13 @@ Note the name change from `http_stream` to `streamable_http`. "name": "my_text_manual", "call_template_type": "text", // Required "file_path": "./manuals/my_manual.json", // Required - "auth": null // Optional (always null for Text) + "auth": null, // Optional (always null for Text) + "auth_tools": { // Optional, authentication for generated tools from OpenAPI specs + "auth_type": "api_key", + "api_key": "Bearer ${API_TOKEN}", + "var_name": "Authorization", + "location": "header" + } } ``` @@ -498,6 +515,81 @@ Note the name change from `http_stream` to `streamable_http`. } ``` +## Security: Protocol Restrictions + +UTCP provides fine-grained control over which communication protocols each manual can use through the `allowed_communication_protocols` field. This prevents potentially dangerous protocol escalation (e.g., an HTTP-based manual accidentally calling CLI tools). + +### Default Behavior (Secure by Default) + +When `allowed_communication_protocols` is not set or is empty, a manual can only register and call tools that use the **same protocol type** as the manual itself: + +```python +from utcp_http.http_call_template import HttpCallTemplate + +# This manual can ONLY register/call HTTP tools (default restriction) +http_manual = HttpCallTemplate( + name="my_api", + call_template_type="http", + url="https://api.example.com/utcp" + # allowed_communication_protocols not set → defaults to ["http"] +) +``` + +### Allowing Multiple Protocols + +To allow a manual to work with tools from multiple protocols, explicitly set `allowed_communication_protocols`: + +```python +from utcp_http.http_call_template import HttpCallTemplate + +# This manual can register/call both HTTP and CLI tools +multi_protocol_manual = HttpCallTemplate( + name="flexible_manual", + call_template_type="http", + url="https://api.example.com/utcp", + allowed_communication_protocols=["http", "cli"] # Explicitly allow both +) +``` + +### JSON Configuration + +```json +{ + "name": "my_api", + "call_template_type": "http", + "url": "https://api.example.com/utcp", + "allowed_communication_protocols": ["http", "cli", "mcp"] +} +``` + +### Behavior Summary + +| `allowed_communication_protocols` | Manual Type | Allowed Tool Protocols | +|----------------------------------|-------------|------------------------| +| Not set / `null` | `"http"` | Only `"http"` | +| `[]` (empty) | `"http"` | Only `"http"` | +| `["http", "cli"]` | `"http"` | `"http"` and `"cli"` | +| `["http", "cli", "mcp"]` | `"cli"` | `"http"`, `"cli"`, and `"mcp"` | + +### Registration Filtering + +During `register_manual()`, tools that don't match the allowed protocols are automatically filtered out with a warning: + +``` +WARNING - Tool 'dangerous_tool' uses communication protocol 'cli' which is not in +allowed protocols ['http'] for manual 'my_api'. Tool will not be registered. +``` + +### Call-Time Validation + +Even if a tool somehow exists in the repository, calling it will fail if its protocol is not allowed: + +```python +# Raises ValueError: Tool 'my_api.some_cli_tool' uses communication protocol 'cli' +# which is not allowed by manual 'my_api'. Allowed protocols: ['http'] +await client.call_tool("my_api.some_cli_tool", {"arg": "value"}) +``` + ## Testing The testing structure has been updated to reflect the new core/plugin split. @@ -535,4 +627,68 @@ The build process now involves building each package (`core` and `plugins`) sepa 4. Run the build: `python -m build`. 5. The distributable files (`.whl` and `.tar.gz`) will be in the `dist/` directory. +## OpenAPI Ingestion - Zero Infrastructure Tool Integration + +🚀 **Transform any existing REST API into UTCP tools without server modifications!** + +UTCP's OpenAPI ingestion feature automatically converts OpenAPI 2.0/3.0 specifications into UTCP tools, enabling AI agents to interact with existing APIs directly - no wrapper servers, no API changes, no additional infrastructure required. + +### Quick Start with OpenAPI + +```python +from utcp_http.openapi_converter import OpenApiConverter +import aiohttp + +# Convert any OpenAPI spec to UTCP tools +async def convert_api(): + async with aiohttp.ClientSession() as session: + async with session.get("https://api.github.com/openapi.json") as response: + openapi_spec = await response.json() + + converter = OpenApiConverter(openapi_spec) + manual = converter.convert() + + print(f"Generated {len(manual.tools)} tools from GitHub API!") + return manual + +# Or use UTCP Client configuration for automatic detection +from utcp.utcp_client import UtcpClient + +client = await UtcpClient.create(config={ + "manual_call_templates": [{ + "name": "github", + "call_template_type": "http", + "url": "https://api.github.com/openapi.json", + "auth_tools": { # Authentication for generated tools requiring auth + "auth_type": "api_key", + "api_key": "Bearer ${GITHUB_TOKEN}", + "var_name": "Authorization", + "location": "header" + } + }] +}) +``` + +### Key Benefits + +- ✅ **Zero Infrastructure**: No servers to deploy or maintain +- ✅ **Direct API Calls**: Native performance, no proxy overhead +- ✅ **Automatic Conversion**: OpenAPI schemas → UTCP tools +- ✅ **Selective Authentication**: Only protected endpoints get auth, public endpoints remain accessible +- ✅ **Authentication Preserved**: API keys, OAuth2, Basic auth supported +- ✅ **Multi-format Support**: JSON, YAML, OpenAPI 2.0/3.0 +- ✅ **Batch Processing**: Convert multiple APIs simultaneously + +### Multiple Ingestion Methods + +1. **Direct Converter**: `OpenApiConverter` class for full control +2. **Remote URLs**: Fetch and convert specs from any URL +3. **Client Configuration**: Include specs directly in UTCP config +4. **Batch Processing**: Process multiple specs programmatically +5. **File-based**: Convert local JSON/YAML specifications + +📖 **[Complete OpenAPI Ingestion Guide](docs/openapi-ingestion.md)** - Detailed examples and advanced usage + +--- + ## [Contributors](https://www.utcp.io/about) diff --git a/core/pyproject.toml b/core/pyproject.toml index 482b147..2844db0 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "utcp" -version = "1.0.4" +version = "1.1.0" authors = [ { name = "UTCP Contributors" }, ] diff --git a/core/src/utcp/data/call_template.py b/core/src/utcp/data/call_template.py index eff0f1b..718f560 100644 --- a/core/src/utcp/data/call_template.py +++ b/core/src/utcp/data/call_template.py @@ -40,11 +40,17 @@ class CallTemplate(BaseModel): Should be unique across all providers and recommended to be set to a human-readable name. Can only contain letters, numbers and underscores. All special characters must be replaced with underscores. call_template_type: The transport protocol type used by this provider. + allowed_communication_protocols: Optional list of communication protocol types that tools + registered under this manual are allowed to use. If None or empty, defaults to only allowing + the same protocol type as the manual's call_template_type. This provides fine-grained security + control - e.g., set to ["http", "cli"] to allow both HTTP and CLI tools, or leave unset to + restrict tools to the manual's own protocol type. """ name: str = Field(default_factory=lambda: uuid.uuid4().hex) call_template_type: str auth: Optional[Auth] = None + allowed_communication_protocols: Optional[List[str]] = None @field_serializer("auth") def serialize_auth(self, auth: Optional[Auth]): diff --git a/core/src/utcp/implementations/utcp_client_implementation.py b/core/src/utcp/implementations/utcp_client_implementation.py index 01c13a9..b88bead 100644 --- a/core/src/utcp/implementations/utcp_client_implementation.py +++ b/core/src/utcp/implementations/utcp_client_implementation.py @@ -95,11 +95,26 @@ async def register_manual(self, manual_call_template: CallTemplate) -> RegisterM """REQUIRED Register a manual in the client. + Registers a manual and its tools with the client. During registration, tools are + filtered based on the manual's `allowed_communication_protocols` setting: + + - If `allowed_communication_protocols` is set to a non-empty list, only tools using + protocols in that list are registered. + - If `allowed_communication_protocols` is None or empty, it defaults to only allowing + the manual's own `call_template_type`. This provides secure-by-default behavior. + + Tools that don't match the allowed protocols are excluded from registration and a + warning is logged for each excluded tool. + Args: manual_call_template: The `CallTemplate` instance representing the manual to register. Returns: - A `RegisterManualResult` instance representing the result of the registration. + A `RegisterManualResult` instance containing the registered tools (filtered by + allowed protocols) and any errors encountered. + + Raises: + ValueError: If manual name is already registered or communication protocol is not found. """ # Replace all non-word characters with underscore manual_call_template.name = re.sub(r'[^\w]', '_', manual_call_template.name) @@ -112,9 +127,27 @@ async def register_manual(self, manual_call_template: CallTemplate) -> RegisterM result = await CommunicationProtocol.communication_protocols[manual_call_template.call_template_type].register_manual(self, manual_call_template) if result.success: + # Determine allowed protocols: use explicit list or default to manual's own protocol + allowed_protocols = manual_call_template.allowed_communication_protocols + if not allowed_protocols: + allowed_protocols = [manual_call_template.call_template_type] + + # Filter tools based on allowed communication protocols + filtered_tools = [] for tool in result.manual.tools: - if not tool.name.startswith(manual_call_template.name + "."): - tool.name = manual_call_template.name + "." + tool.name + tool_protocol = tool.tool_call_template.call_template_type if tool.tool_call_template else manual_call_template.call_template_type + if tool_protocol in allowed_protocols: + if not tool.name.startswith(manual_call_template.name + "."): + tool.name = manual_call_template.name + "." + tool.name + filtered_tools.append(tool) + else: + logger.warning( + f"Tool '{tool.name}' uses communication protocol '{tool_protocol}' " + f"which is not in allowed protocols {allowed_protocols} for manual '{manual_call_template.name}'. " + f"Tool will not be registered." + ) + + result.manual.tools = filtered_tools await self.config.tool_repository.save_manual(result.manual_call_template, result.manual) return result @@ -177,12 +210,25 @@ async def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any: """REQUIRED Call a tool in the client. + Executes a registered tool with the provided arguments. Before execution, validates + that the tool's communication protocol is allowed by the parent manual's + `allowed_communication_protocols` setting: + + - If `allowed_communication_protocols` is set to a non-empty list, the tool's protocol + must be in that list. + - If `allowed_communication_protocols` is None or empty, only tools using the manual's + own `call_template_type` are allowed. + Args: - tool_name: The name of the tool to call. + tool_name: The fully qualified name of the tool (e.g., "manual_name.tool_name"). tool_args: A dictionary of arguments to pass to the tool. Returns: - The result of the tool call. + The result of the tool call, after any post-processing. + + Raises: + ValueError: If the tool is not found or if the tool's communication protocol + is not in the manual's allowed protocols. """ manual_name = tool_name.split(".")[0] tool = await self.config.tool_repository.get_tool(tool_name) @@ -190,6 +236,20 @@ async def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any: raise ValueError(f"Tool not found: {tool_name}") tool_call_template = tool.tool_call_template tool_call_template = self._substitute_call_template_variables(tool_call_template, manual_name) + + # Check if the tool's communication protocol is allowed by the manual + manual_call_template = await self.config.tool_repository.get_manual_call_template(manual_name) + if manual_call_template: + allowed_protocols = manual_call_template.allowed_communication_protocols + if not allowed_protocols: + allowed_protocols = [manual_call_template.call_template_type] + if tool_call_template.call_template_type not in allowed_protocols: + raise ValueError( + f"Tool '{tool_name}' uses communication protocol '{tool_call_template.call_template_type}' " + f"which is not allowed by manual '{manual_name}'. " + f"Allowed protocols: {allowed_protocols}" + ) + result = await CommunicationProtocol.communication_protocols[tool_call_template.call_template_type].call_tool(self, tool_name, tool_args, tool_call_template) for post_processor in self.config.post_processing: @@ -198,14 +258,27 @@ async def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any: async def call_tool_streaming(self, tool_name: str, tool_args: Dict[str, Any]) -> AsyncGenerator[Any, None]: """REQUIRED - Call a tool in the client streamingly. + Call a tool in the client with streaming response. + + Executes a registered tool with streaming output. Before execution, validates + that the tool's communication protocol is allowed by the parent manual's + `allowed_communication_protocols` setting: + + - If `allowed_communication_protocols` is set to a non-empty list, the tool's protocol + must be in that list. + - If `allowed_communication_protocols` is None or empty, only tools using the manual's + own `call_template_type` are allowed. Args: - tool_name: The name of the tool to call. + tool_name: The fully qualified name of the tool (e.g., "manual_name.tool_name"). tool_args: A dictionary of arguments to pass to the tool. - Returns: - An async generator yielding the result of the tool call. + Yields: + Chunks of the tool's streaming response, after any post-processing. + + Raises: + ValueError: If the tool is not found or if the tool's communication protocol + is not in the manual's allowed protocols. """ manual_name = tool_name.split(".")[0] tool = await self.config.tool_repository.get_tool(tool_name) @@ -213,6 +286,20 @@ async def call_tool_streaming(self, tool_name: str, tool_args: Dict[str, Any]) - raise ValueError(f"Tool not found: {tool_name}") tool_call_template = tool.tool_call_template tool_call_template = self._substitute_call_template_variables(tool_call_template, manual_name) + + # Check if the tool's communication protocol is allowed by the manual + manual_call_template = await self.config.tool_repository.get_manual_call_template(manual_name) + if manual_call_template: + allowed_protocols = manual_call_template.allowed_communication_protocols + if not allowed_protocols: + allowed_protocols = [manual_call_template.call_template_type] + if tool_call_template.call_template_type not in allowed_protocols: + raise ValueError( + f"Tool '{tool_name}' uses communication protocol '{tool_call_template.call_template_type}' " + f"which is not allowed by manual '{manual_name}'. " + f"Allowed protocols: {allowed_protocols}" + ) + async for item in CommunicationProtocol.communication_protocols[tool_call_template.call_template_type].call_tool_streaming(self, tool_name, tool_args, tool_call_template): for post_processor in self.config.post_processing: item = post_processor.post_process(self, tool, tool_call_template, item) diff --git a/core/tests/client/test_utcp_client.py b/core/tests/client/test_utcp_client.py index 34c408f..934bebf 100644 --- a/core/tests/client/test_utcp_client.py +++ b/core/tests/client/test_utcp_client.py @@ -725,6 +725,236 @@ async def test_load_call_templates_wrong_format(self): os.unlink(temp_file) +class TestAllowedCommunicationProtocols: + """Test allowed_communication_protocols restriction functionality.""" + + @pytest.mark.asyncio + async def test_call_tool_allowed_protocol(self, utcp_client, sample_tools, isolated_communication_protocols): + """Test calling a tool when its protocol is in the allowed list.""" + client = utcp_client + call_template = HttpCallTemplate( + name="test_manual", + url="https://api.example.com/tool", + http_method="POST", + call_template_type="http", + allowed_communication_protocols=["http", "cli"] # Allow both HTTP and CLI + ) + + manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=sample_tools[:1]) + mock_protocol = MockCommunicationProtocol(manual, "test_result") + CommunicationProtocol.communication_protocols["http"] = mock_protocol + + await client.register_manual(call_template) + + # Call should succeed since "http" is in allowed_communication_protocols + result = await client.call_tool("test_manual.http_tool", {"param1": "value1"}) + assert result == "test_result" + + @pytest.mark.asyncio + async def test_register_filters_disallowed_protocol_tools(self, utcp_client, sample_tools, isolated_communication_protocols): + """Test that tools with disallowed protocols are filtered during registration.""" + client = utcp_client + + # Register HTTP manual that only allows "http" protocol + http_call_template = HttpCallTemplate( + name="http_manual", + url="https://api.example.com/tool", + http_method="POST", + call_template_type="http", + allowed_communication_protocols=["http"] # Only allow HTTP + ) + + # Create a tool that uses CLI protocol (which is not allowed) + cli_tool = Tool( + name="cli_tool", + description="CLI test tool", + inputs=JsonSchema( + type="object", + properties={"command": {"type": "string", "description": "Command to execute"}}, + required=["command"] + ), + outputs=JsonSchema( + type="object", + properties={"output": {"type": "string", "description": "Command output"}} + ), + tags=["cli", "test"], + tool_call_template=CliCallTemplate( + name="cli_provider", + commands=[{"command": "echo UTCP_ARG_command_UTCP_END"}], + call_template_type="cli" + ) + ) + + manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[cli_tool]) + mock_http_protocol = MockCommunicationProtocol(manual) + mock_cli_protocol = MockCommunicationProtocol() + CommunicationProtocol.communication_protocols["http"] = mock_http_protocol + CommunicationProtocol.communication_protocols["cli"] = mock_cli_protocol + + result = await client.register_manual(http_call_template) + + # CLI tool should be filtered out during registration + assert len(result.manual.tools) == 0 + + # Tool should not exist in repository + tool = await client.config.tool_repository.get_tool("http_manual.cli_tool") + assert tool is None + + @pytest.mark.asyncio + async def test_call_tool_default_protocol_restriction(self, utcp_client, sample_tools, isolated_communication_protocols): + """Test that when no allowed_communication_protocols is set, only the manual's protocol is allowed.""" + client = utcp_client + + # Register HTTP manual without explicit protocol restrictions + # Default behavior: only HTTP tools should be allowed + http_call_template = HttpCallTemplate( + name="http_manual", + url="https://api.example.com/tool", + http_method="POST", + call_template_type="http" + # No allowed_communication_protocols set - defaults to ["http"] + ) + + # Create tools: one HTTP (should be registered), one CLI (should be filtered out) + http_tool = Tool( + name="http_tool", + description="HTTP test tool", + inputs=JsonSchema(type="object", properties={}), + outputs=JsonSchema(type="object", properties={}), + tool_call_template=HttpCallTemplate( + name="http_provider", + url="https://api.example.com/call", + http_method="GET", + call_template_type="http" + ) + ) + cli_tool = Tool( + name="cli_tool", + description="CLI test tool", + inputs=JsonSchema(type="object", properties={}), + outputs=JsonSchema(type="object", properties={}), + tool_call_template=CliCallTemplate( + name="cli_provider", + commands=[{"command": "echo test"}], + call_template_type="cli" + ) + ) + + manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[http_tool, cli_tool]) + mock_http_protocol = MockCommunicationProtocol(manual, call_result="http_result") + mock_cli_protocol = MockCommunicationProtocol() + CommunicationProtocol.communication_protocols["http"] = mock_http_protocol + CommunicationProtocol.communication_protocols["cli"] = mock_cli_protocol + + result = await client.register_manual(http_call_template) + + # Only HTTP tool should be registered, CLI tool should be filtered out + assert len(result.manual.tools) == 1 + assert result.manual.tools[0].name == "http_manual.http_tool" + + # HTTP tool call should succeed + call_result = await client.call_tool("http_manual.http_tool", {}) + assert call_result == "http_result" + + # CLI tool should not exist in repository + cli_tool_in_repo = await client.config.tool_repository.get_tool("http_manual.cli_tool") + assert cli_tool_in_repo is None + + @pytest.mark.asyncio + async def test_register_with_multiple_allowed_protocols(self, utcp_client, sample_tools, isolated_communication_protocols): + """Test registration with multiple allowed protocols allows all specified types.""" + client = utcp_client + + http_call_template = HttpCallTemplate( + name="multi_protocol_manual", + url="https://api.example.com/tool", + http_method="POST", + call_template_type="http", + allowed_communication_protocols=["http", "cli"] # Allow both + ) + + http_tool = Tool( + name="http_tool", + description="HTTP test tool", + inputs=JsonSchema(type="object", properties={}), + outputs=JsonSchema(type="object", properties={}), + tool_call_template=HttpCallTemplate( + name="http_provider", + url="https://api.example.com/call", + http_method="GET", + call_template_type="http" + ) + ) + cli_tool = Tool( + name="cli_tool", + description="CLI test tool", + inputs=JsonSchema(type="object", properties={}), + outputs=JsonSchema(type="object", properties={}), + tool_call_template=CliCallTemplate( + name="cli_provider", + commands=[{"command": "echo test"}], + call_template_type="cli" + ) + ) + + manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[http_tool, cli_tool]) + mock_http_protocol = MockCommunicationProtocol(manual, call_result="http_result") + mock_cli_protocol = MockCommunicationProtocol(call_result="cli_result") + CommunicationProtocol.communication_protocols["http"] = mock_http_protocol + CommunicationProtocol.communication_protocols["cli"] = mock_cli_protocol + + result = await client.register_manual(http_call_template) + + # Both tools should be registered + assert len(result.manual.tools) == 2 + tool_names = [t.name for t in result.manual.tools] + assert "multi_protocol_manual.http_tool" in tool_names + assert "multi_protocol_manual.cli_tool" in tool_names + + # Both tools should be callable + http_result = await client.call_tool("multi_protocol_manual.http_tool", {}) + assert http_result == "http_result" + + cli_result = await client.call_tool("multi_protocol_manual.cli_tool", {}) + assert cli_result == "cli_result" + + @pytest.mark.asyncio + async def test_call_tool_empty_allowed_protocols_defaults_to_manual_type(self, utcp_client, sample_tools, isolated_communication_protocols): + """Test that empty allowed_communication_protocols defaults to manual's protocol type.""" + client = utcp_client + + http_call_template = HttpCallTemplate( + name="http_manual", + url="https://api.example.com/tool", + http_method="POST", + call_template_type="http", + allowed_communication_protocols=[] # Empty list defaults to ["http"] + ) + + cli_tool = Tool( + name="cli_tool", + description="CLI test tool", + inputs=JsonSchema(type="object", properties={}), + outputs=JsonSchema(type="object", properties={}), + tool_call_template=CliCallTemplate( + name="cli_provider", + commands=[{"command": "echo test"}], + call_template_type="cli" + ) + ) + + manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[cli_tool]) + mock_http_protocol = MockCommunicationProtocol(manual) + mock_cli_protocol = MockCommunicationProtocol(call_result="cli_result") + CommunicationProtocol.communication_protocols["http"] = mock_http_protocol + CommunicationProtocol.communication_protocols["cli"] = mock_cli_protocol + + result = await client.register_manual(http_call_template) + + # CLI tool should be filtered out during registration + assert len(result.manual.tools) == 0 + + class TestToolSerialization: """Test Tool and JsonSchema serialization."""