From 434f81f638d82fbd8a65955b7cf37dd1b1fe7330 Mon Sep 17 00:00:00 2001 From: Sooriya04 Date: Wed, 4 Nov 2026 23:18:21 +0530 Subject: [PATCH 1/6] fix(http): serialize object and boolean query parameters to prevent TypeError Fixes #107. - JSON-encodes object/dictionary and set query parameters so nested content-form query parameters do not raise unhandled TypeError in aiohttp. - Normalizes boolean parameters to 'true'/'false'. - Preserves sequence/list repetition (?tag=a&tag=b) and primitives. - Applied uniformly across HttpCommunicationProtocol, SseCommunicationProtocol, and StreamableHttpCommunicationProtocol. - Adds regression unit test verifying object and list query parameter serialization. --- .../utcp_http/http_communication_protocol.py | 26 ++++++++++++++- .../utcp_http/sse_communication_protocol.py | 26 ++++++++++++++- .../streamable_http_communication_protocol.py | 26 ++++++++++++++- .../tests/test_http_communication_protocol.py | 32 +++++++++++++++++++ 4 files changed, 107 insertions(+), 3 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 141a979..acc57f5 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 @@ -332,7 +332,7 @@ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], too ensure_loopback_was_declared(tool_call_template.url, url, context="tool invocation") # The rest of the arguments are query parameters - query_params = remaining_args + query_params = self._encode_query_params(remaining_args) # Handle authentication auth, cookies, auth_header_names = self._apply_auth(tool_call_template, request_headers, query_params) @@ -482,6 +482,30 @@ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: except aiohttp.ClientError as e: logger.error(f"OAuth2 with Basic Auth header also failed: {e}") + def _encode_query_params(self, params: Dict[str, Any]) -> Dict[str, Any]: + """REQUIRED + Encode query parameter values for HTTP requests. + + Primitives (str, int, float) are preserved. Booleans are serialized + as 'true'/'false'. Lists and tuples are recursively encoded so aiohttp + can send repeated query parameters. Dicts, sets, and other composite objects + are JSON-encoded to prevent aiohttp from raising a TypeError. + """ + def _encode_val(val: Any) -> Any: + if val is None: + return "" + if isinstance(val, bool): + return "true" if val else "false" + if isinstance(val, (int, float, str)): + return val + if isinstance(val, (list, tuple)): + return [_encode_val(item) for item in val] + if isinstance(val, (dict, set)): + return json.dumps(val, separators=(",", ":")) + return str(val) + + return {k: _encode_val(v) for k, v in params.items()} + def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, Any]) -> str: """ Build URL by substituting path parameters from arguments. 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 fa4ca72..dafb071 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 @@ -246,7 +246,7 @@ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, ensure_loopback_was_declared(tool_call_template.url, url, context="tool invocation") # The rest of the arguments are query parameters - query_params = remaining_args + query_params = self._encode_query_params(remaining_args) # Handle authentication # ``auth_header_names`` unused in the streaming path because @@ -534,6 +534,30 @@ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: logger.error(f"OAuth2 with header failed: {e}") raise e + def _encode_query_params(self, params: Dict[str, Any]) -> Dict[str, Any]: + """REQUIRED + Encode query parameter values for HTTP requests. + + Primitives (str, int, float) are preserved. Booleans are serialized + as 'true'/'false'. Lists and tuples are recursively encoded so aiohttp + can send repeated query parameters. Dicts, sets, and other composite objects + are JSON-encoded to prevent aiohttp from raising a TypeError. + """ + def _encode_val(val: Any) -> Any: + if val is None: + return "" + if isinstance(val, bool): + return "true" if val else "false" + if isinstance(val, (int, float, str)): + return val + if isinstance(val, (list, tuple)): + return [_encode_val(item) for item in val] + if isinstance(val, (dict, set)): + return json.dumps(val, separators=(",", ":")) + return str(val) + + return {k: _encode_val(v) for k, v in params.items()} + def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, Any]) -> str: """Build URL by substituting path parameters from arguments. 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 c25b43d..4d720d2 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 @@ -251,7 +251,7 @@ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, ensure_loopback_was_declared(tool_call_template.url, url, context="tool invocation") # The rest of the arguments are query parameters - query_params = remaining_args + query_params = self._encode_query_params(remaining_args) # Handle authentication # ``auth_header_names`` unused here -- streaming handshake uses @@ -426,6 +426,30 @@ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: logger.error(f"OAuth2 with Basic Auth header also failed: {e}") raise e + def _encode_query_params(self, params: Dict[str, Any]) -> Dict[str, Any]: + """REQUIRED + Encode query parameter values for HTTP requests. + + Primitives (str, int, float) are preserved. Booleans are serialized + as 'true'/'false'. Lists and tuples are recursively encoded so aiohttp + can send repeated query parameters. Dicts, sets, and other composite objects + are JSON-encoded to prevent aiohttp from raising a TypeError. + """ + def _encode_val(val: Any) -> Any: + if val is None: + return "" + if isinstance(val, bool): + return "true" if val else "false" + if isinstance(val, (int, float, str)): + return val + if isinstance(val, (list, tuple)): + return [_encode_val(item) for item in val] + if isinstance(val, (dict, set)): + return json.dumps(val, separators=(",", ":")) + return str(val) + + return {k: _encode_val(v) for k, v in params.items()} + def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, Any]) -> str: """Build URL by substituting path parameters from arguments. diff --git a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py index 4504e2c..efffd9d 100644 --- a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py +++ b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py @@ -163,6 +163,15 @@ async def forbidden_huge_handler(request): return web.Response(status=403, text="x" * (1024 * 1024)) app.router.add_route('*', '/forbidden-object-then-message', forbidden_object_then_message_handler) + + # Echo query parameters endpoint for testing serialization + async def echo_query_handler(request): + return web.json_response({ + "query": dict(request.query), + "multi_query": {k: request.query.getall(k) for k in request.query.keys()}, + }) + + app.router.add_get('/echo-query', echo_query_handler) app.router.add_route('*', '/forbidden-huge', forbidden_huge_handler) # No Content-Type at all, so no charset to decode with. @@ -887,3 +896,26 @@ async def test_deeply_nested_error_body_still_raises_client_response_error(http_ def test_error_detail_collapses_control_characters(): from utcp_http._errors import error_detail_from_body assert error_detail_from_body('{"error": "line one\\nline two\\u001b[31m"}') == "line one line two [31m" + + +@pytest.mark.asyncio +async def test_call_tool_encodes_object_and_list_query_parameters(http_transport, aiohttp_client, app): + """Verify dicts and sets are JSON-encoded and sequences are properly passed without TypeError (#107).""" + client = await aiohttp_client(app) + call_template = HttpCallTemplate( + name="echo_tool", + url=f"http://localhost:{client.port}/echo-query", + http_method="GET", + ) + tool_args = { + "filter": {"status": "open", "nested": {"key": 1}}, + "tags": ["alpha", "beta"], + "active": True, + "count": 42, + } + result = await http_transport.call_tool(None, "echo_tool", tool_args, call_template) + assert result["query"]["filter"] == '{"status":"open","nested":{"key":1}}' + assert result["query"]["active"] == "true" + assert result["query"]["count"] == "42" + assert result["multi_query"]["tags"] == ["alpha", "beta"] + From 4a39d7d1a803069b70c3fe73140b2936749a8e25 Mon Sep 17 00:00:00 2001 From: Sooriya04 Date: Wed, 4 Nov 2026 23:28:45 +0530 Subject: [PATCH 2/6] refactor(http): consolidate query param encoding and handle sets and nested sequences Addresses cubic-dev-ai review feedback on PR #122: - Consolidates _encode_query_params into shared encode_query_params in _security.py. - Converts set values into sorted JSON arrays to avoid TypeError from json.dumps. - JSON-encodes nested sequences/lists so aiohttp does not raise TypeError on matrix/nested arguments. - Adds test cases for set and nested sequence arguments to test_http_communication_protocol.py. --- .../http/src/utcp_http/_security.py | 34 +++++++++++++++++++ .../utcp_http/http_communication_protocol.py | 27 ++------------- .../utcp_http/sse_communication_protocol.py | 27 ++------------- .../streamable_http_communication_protocol.py | 27 ++------------- .../tests/test_http_communication_protocol.py | 7 +++- 5 files changed, 46 insertions(+), 76 deletions(-) diff --git a/plugins/communication_protocols/http/src/utcp_http/_security.py b/plugins/communication_protocols/http/src/utcp_http/_security.py index 0cc5791..dbe9607 100644 --- a/plugins/communication_protocols/http/src/utcp_http/_security.py +++ b/plugins/communication_protocols/http/src/utcp_http/_security.py @@ -9,6 +9,7 @@ from __future__ import annotations +import json import re import socket from contextlib import asynccontextmanager @@ -648,3 +649,36 @@ def reject_remote_loopback_tool_urls( "allowed to route the client-credentials POST at the agent's " "own loopback interface." ) + + +def encode_query_params(params: Dict[str, Any]) -> Dict[str, Any]: + """Encode query parameter values for HTTP/SSE/Streamable HTTP requests. + + - Preserves primitive values (str, int, float). + - Serializes booleans as 'true'/'false'. + - Serializes sets into JSON arrays with sorted elements for deterministic output. + - Serializes dictionaries as compact JSON objects. + - Preserves top-level lists and tuples so aiohttp generates repeated query + parameters (?key=v1&key=v2), while recursively encoding nested sequences + or objects within list items into JSON. + """ + def _encode_item(item: Any) -> Any: + if item is None: + return "" + if isinstance(item, bool): + return "true" if item else "false" + if isinstance(item, (int, float, str)): + return item + if isinstance(item, set): + return json.dumps(sorted(item, key=str), separators=(",", ":")) + if isinstance(item, (dict, list, tuple)): + return json.dumps(item, separators=(",", ":")) + return str(item) + + def _encode_top(val: Any) -> Any: + if isinstance(val, (list, tuple)): + return [_encode_item(sub) for sub in val] + return _encode_item(val) + + return {k: _encode_top(v) for k, v in params.items()} + 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 acc57f5..fc1bc9c 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 @@ -38,6 +38,7 @@ ensure_loopback_was_declared, safe_request_with_redirects, reject_remote_loopback_tool_urls, + encode_query_params, ) from utcp_http._errors import raise_for_status_with_body import logging @@ -332,7 +333,7 @@ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], too ensure_loopback_was_declared(tool_call_template.url, url, context="tool invocation") # The rest of the arguments are query parameters - query_params = self._encode_query_params(remaining_args) + query_params = encode_query_params(remaining_args) # Handle authentication auth, cookies, auth_header_names = self._apply_auth(tool_call_template, request_headers, query_params) @@ -482,30 +483,6 @@ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: except aiohttp.ClientError as e: logger.error(f"OAuth2 with Basic Auth header also failed: {e}") - def _encode_query_params(self, params: Dict[str, Any]) -> Dict[str, Any]: - """REQUIRED - Encode query parameter values for HTTP requests. - - Primitives (str, int, float) are preserved. Booleans are serialized - as 'true'/'false'. Lists and tuples are recursively encoded so aiohttp - can send repeated query parameters. Dicts, sets, and other composite objects - are JSON-encoded to prevent aiohttp from raising a TypeError. - """ - def _encode_val(val: Any) -> Any: - if val is None: - return "" - if isinstance(val, bool): - return "true" if val else "false" - if isinstance(val, (int, float, str)): - return val - if isinstance(val, (list, tuple)): - return [_encode_val(item) for item in val] - if isinstance(val, (dict, set)): - return json.dumps(val, separators=(",", ":")) - return str(val) - - return {k: _encode_val(v) for k, v in params.items()} - def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, Any]) -> str: """ Build URL by substituting path parameters from arguments. 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 dafb071..d96e0a3 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 @@ -24,6 +24,7 @@ ensure_loopback_was_declared, safe_request_with_redirects, reject_remote_loopback_tool_urls, + encode_query_params, ) import traceback import logging @@ -246,7 +247,7 @@ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, ensure_loopback_was_declared(tool_call_template.url, url, context="tool invocation") # The rest of the arguments are query parameters - query_params = self._encode_query_params(remaining_args) + query_params = encode_query_params(remaining_args) # Handle authentication # ``auth_header_names`` unused in the streaming path because @@ -534,30 +535,6 @@ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: logger.error(f"OAuth2 with header failed: {e}") raise e - def _encode_query_params(self, params: Dict[str, Any]) -> Dict[str, Any]: - """REQUIRED - Encode query parameter values for HTTP requests. - - Primitives (str, int, float) are preserved. Booleans are serialized - as 'true'/'false'. Lists and tuples are recursively encoded so aiohttp - can send repeated query parameters. Dicts, sets, and other composite objects - are JSON-encoded to prevent aiohttp from raising a TypeError. - """ - def _encode_val(val: Any) -> Any: - if val is None: - return "" - if isinstance(val, bool): - return "true" if val else "false" - if isinstance(val, (int, float, str)): - return val - if isinstance(val, (list, tuple)): - return [_encode_val(item) for item in val] - if isinstance(val, (dict, set)): - return json.dumps(val, separators=(",", ":")) - return str(val) - - return {k: _encode_val(v) for k, v in params.items()} - def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, Any]) -> str: """Build URL by substituting path parameters from arguments. 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 4d720d2..10f287f 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 @@ -21,6 +21,7 @@ ensure_loopback_was_declared, safe_request_with_redirects, reject_remote_loopback_tool_urls, + encode_query_params, ) import logging @@ -251,7 +252,7 @@ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, ensure_loopback_was_declared(tool_call_template.url, url, context="tool invocation") # The rest of the arguments are query parameters - query_params = self._encode_query_params(remaining_args) + query_params = encode_query_params(remaining_args) # Handle authentication # ``auth_header_names`` unused here -- streaming handshake uses @@ -426,30 +427,6 @@ async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str: logger.error(f"OAuth2 with Basic Auth header also failed: {e}") raise e - def _encode_query_params(self, params: Dict[str, Any]) -> Dict[str, Any]: - """REQUIRED - Encode query parameter values for HTTP requests. - - Primitives (str, int, float) are preserved. Booleans are serialized - as 'true'/'false'. Lists and tuples are recursively encoded so aiohttp - can send repeated query parameters. Dicts, sets, and other composite objects - are JSON-encoded to prevent aiohttp from raising a TypeError. - """ - def _encode_val(val: Any) -> Any: - if val is None: - return "" - if isinstance(val, bool): - return "true" if val else "false" - if isinstance(val, (int, float, str)): - return val - if isinstance(val, (list, tuple)): - return [_encode_val(item) for item in val] - if isinstance(val, (dict, set)): - return json.dumps(val, separators=(",", ":")) - return str(val) - - return {k: _encode_val(v) for k, v in params.items()} - def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, Any]) -> str: """Build URL by substituting path parameters from arguments. diff --git a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py index efffd9d..aa86357 100644 --- a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py +++ b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py @@ -900,7 +900,7 @@ def test_error_detail_collapses_control_characters(): @pytest.mark.asyncio async def test_call_tool_encodes_object_and_list_query_parameters(http_transport, aiohttp_client, app): - """Verify dicts and sets are JSON-encoded and sequences are properly passed without TypeError (#107).""" + """Verify dicts, sets, nested sequences, and primitives are properly encoded without TypeError (#107).""" client = await aiohttp_client(app) call_template = HttpCallTemplate( name="echo_tool", @@ -912,10 +912,15 @@ async def test_call_tool_encodes_object_and_list_query_parameters(http_transport "tags": ["alpha", "beta"], "active": True, "count": 42, + "categories": {"electronics", "books"}, + "matrix": [[1, 2], [3, 4]], } result = await http_transport.call_tool(None, "echo_tool", tool_args, call_template) assert result["query"]["filter"] == '{"status":"open","nested":{"key":1}}' assert result["query"]["active"] == "true" assert result["query"]["count"] == "42" + assert result["query"]["categories"] == '["books","electronics"]' assert result["multi_query"]["tags"] == ["alpha", "beta"] + assert result["multi_query"]["matrix"] == ["[1,2]", "[3,4]"] + From af5adc51859c652efc5af869fb42f35b4e32e3a0 Mon Sep 17 00:00:00 2001 From: Sooriya04 Date: Wed, 4 Nov 2026 23:36:02 +0530 Subject: [PATCH 3/6] fix(http): drop None values in query param encoding - Drops query parameters with None values so optional unset arguments are not sent as 'key=' on the wire. - Adds test assertion verifying 'optional_unset' is omitted from the request query. --- .../http/src/utcp_http/_security.py | 25 +++++++++++++------ .../tests/test_http_communication_protocol.py | 5 +++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/plugins/communication_protocols/http/src/utcp_http/_security.py b/plugins/communication_protocols/http/src/utcp_http/_security.py index dbe9607..97e2ad0 100644 --- a/plugins/communication_protocols/http/src/utcp_http/_security.py +++ b/plugins/communication_protocols/http/src/utcp_http/_security.py @@ -654,17 +654,18 @@ def reject_remote_loopback_tool_urls( def encode_query_params(params: Dict[str, Any]) -> Dict[str, Any]: """Encode query parameter values for HTTP/SSE/Streamable HTTP requests. + - Drops None values so optional unset arguments are omitted from query strings. - Preserves primitive values (str, int, float). - Serializes booleans as 'true'/'false'. - Serializes sets into JSON arrays with sorted elements for deterministic output. - Serializes dictionaries as compact JSON objects. - Preserves top-level lists and tuples so aiohttp generates repeated query parameters (?key=v1&key=v2), while recursively encoding nested sequences - or objects within list items into JSON. + or objects within list items into JSON (omitting None items). """ - def _encode_item(item: Any) -> Any: + def _encode_item(item: Any) -> Optional[Any]: if item is None: - return "" + return None if isinstance(item, bool): return "true" if item else "false" if isinstance(item, (int, float, str)): @@ -675,10 +676,18 @@ def _encode_item(item: Any) -> Any: return json.dumps(item, separators=(",", ":")) return str(item) - def _encode_top(val: Any) -> Any: - if isinstance(val, (list, tuple)): - return [_encode_item(sub) for sub in val] - return _encode_item(val) + result: Dict[str, Any] = {} + for k, v in params.items(): + if v is None: + continue + if isinstance(v, (list, tuple)): + encoded_items = [_encode_item(sub) for sub in v if sub is not None] + result[k] = encoded_items + else: + encoded_val = _encode_item(v) + if encoded_val is not None: + result[k] = encoded_val + + return result - return {k: _encode_top(v) for k, v in params.items()} diff --git a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py index aa86357..84519c8 100644 --- a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py +++ b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py @@ -900,7 +900,7 @@ def test_error_detail_collapses_control_characters(): @pytest.mark.asyncio async def test_call_tool_encodes_object_and_list_query_parameters(http_transport, aiohttp_client, app): - """Verify dicts, sets, nested sequences, and primitives are properly encoded without TypeError (#107).""" + """Verify dicts, sets, nested sequences, and primitives are properly encoded, and None is dropped (#107).""" client = await aiohttp_client(app) call_template = HttpCallTemplate( name="echo_tool", @@ -914,13 +914,16 @@ async def test_call_tool_encodes_object_and_list_query_parameters(http_transport "count": 42, "categories": {"electronics", "books"}, "matrix": [[1, 2], [3, 4]], + "optional_unset": None, } result = await http_transport.call_tool(None, "echo_tool", tool_args, call_template) assert result["query"]["filter"] == '{"status":"open","nested":{"key":1}}' assert result["query"]["active"] == "true" assert result["query"]["count"] == "42" assert result["query"]["categories"] == '["books","electronics"]' + assert "optional_unset" not in result["query"] assert result["multi_query"]["tags"] == ["alpha", "beta"] assert result["multi_query"]["matrix"] == ["[1,2]", "[3,4]"] + From 4f2639d32cada59d3ba48da5ad647833138c0066 Mon Sep 17 00:00:00 2001 From: Sooriya04 Date: Wed, 4 Nov 2026 23:43:38 +0530 Subject: [PATCH 4/6] fix(http): support non-JSON objects and sets nested inside dict/list query arguments - Adds a default handler to json.dumps in encode_query_params: - Nested sets/frozensets serialize to sorted lists. - Other non-JSON types fall back to str(obj). - Updates regression test in test_http_communication_protocol.py with nested sets. --- .../http/src/utcp_http/_security.py | 14 +++++++++++--- .../http/tests/test_http_communication_protocol.py | 5 +++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/plugins/communication_protocols/http/src/utcp_http/_security.py b/plugins/communication_protocols/http/src/utcp_http/_security.py index 97e2ad0..ee8b3bb 100644 --- a/plugins/communication_protocols/http/src/utcp_http/_security.py +++ b/plugins/communication_protocols/http/src/utcp_http/_security.py @@ -662,7 +662,14 @@ def encode_query_params(params: Dict[str, Any]) -> Dict[str, Any]: - Preserves top-level lists and tuples so aiohttp generates repeated query parameters (?key=v1&key=v2), while recursively encoding nested sequences or objects within list items into JSON (omitting None items). + - Non-JSON types (like sets or custom objects) nested inside dicts or lists + are safely handled via a default serializer fallback. """ + def _json_default(obj: Any) -> Any: + if isinstance(obj, (set, frozenset)): + return sorted(obj, key=str) + return str(obj) + def _encode_item(item: Any) -> Optional[Any]: if item is None: return None @@ -670,10 +677,10 @@ def _encode_item(item: Any) -> Optional[Any]: return "true" if item else "false" if isinstance(item, (int, float, str)): return item - if isinstance(item, set): - return json.dumps(sorted(item, key=str), separators=(",", ":")) + if isinstance(item, (set, frozenset)): + return json.dumps(sorted(item, key=str), default=_json_default, separators=(",", ":")) if isinstance(item, (dict, list, tuple)): - return json.dumps(item, separators=(",", ":")) + return json.dumps(item, default=_json_default, separators=(",", ":")) return str(item) result: Dict[str, Any] = {} @@ -691,3 +698,4 @@ def _encode_item(item: Any) -> Optional[Any]: return result + diff --git a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py index 84519c8..cdfdeb9 100644 --- a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py +++ b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py @@ -908,7 +908,7 @@ async def test_call_tool_encodes_object_and_list_query_parameters(http_transport http_method="GET", ) tool_args = { - "filter": {"status": "open", "nested": {"key": 1}}, + "filter": {"status": "open", "nested": {"key": 1}, "tags": {"python", "async"}}, "tags": ["alpha", "beta"], "active": True, "count": 42, @@ -917,7 +917,7 @@ async def test_call_tool_encodes_object_and_list_query_parameters(http_transport "optional_unset": None, } result = await http_transport.call_tool(None, "echo_tool", tool_args, call_template) - assert result["query"]["filter"] == '{"status":"open","nested":{"key":1}}' + assert result["query"]["filter"] == '{"status":"open","nested":{"key":1},"tags":["async","python"]}' assert result["query"]["active"] == "true" assert result["query"]["count"] == "42" assert result["query"]["categories"] == '["books","electronics"]' @@ -927,3 +927,4 @@ async def test_call_tool_encodes_object_and_list_query_parameters(http_transport + From 57bca086c8ad253c9ea60b7a32a0cd475725e336 Mon Sep 17 00:00:00 2001 From: Sooriya04 Date: Wed, 4 Nov 2026 23:49:57 +0530 Subject: [PATCH 5/6] fix(http): normalize non-string dictionary keys before JSON encoding query parameters - Normalizes nested dictionaries so non-string keys (e.g. ints or tuples) become strings before json.dumps, preventing TypeError since json.dumps default handler only operates on values. - Recursively sorts sets into lists and preserves nested structures. - Adds regression test with numeric dictionary key {100: 'numeric_key'}. --- .../http/src/utcp_http/_security.py | 26 ++++++++++++------- .../tests/test_http_communication_protocol.py | 5 ++-- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/plugins/communication_protocols/http/src/utcp_http/_security.py b/plugins/communication_protocols/http/src/utcp_http/_security.py index ee8b3bb..7500d1f 100644 --- a/plugins/communication_protocols/http/src/utcp_http/_security.py +++ b/plugins/communication_protocols/http/src/utcp_http/_security.py @@ -658,17 +658,21 @@ def encode_query_params(params: Dict[str, Any]) -> Dict[str, Any]: - Preserves primitive values (str, int, float). - Serializes booleans as 'true'/'false'. - Serializes sets into JSON arrays with sorted elements for deterministic output. - - Serializes dictionaries as compact JSON objects. + - Serializes dictionaries as compact JSON objects with string-normalized keys. - Preserves top-level lists and tuples so aiohttp generates repeated query parameters (?key=v1&key=v2), while recursively encoding nested sequences or objects within list items into JSON (omitting None items). - - Non-JSON types (like sets or custom objects) nested inside dicts or lists - are safely handled via a default serializer fallback. + - Non-JSON types (like sets or custom objects) and non-string mapping keys + nested inside dicts or lists are safely normalized before dumping. """ - def _json_default(obj: Any) -> Any: + def _normalize(obj: Any) -> Any: + if isinstance(obj, dict): + return {str(k): _normalize(v) for k, v in obj.items()} if isinstance(obj, (set, frozenset)): - return sorted(obj, key=str) - return str(obj) + return [_normalize(x) for x in sorted(obj, key=str)] + if isinstance(obj, (list, tuple)): + return [_normalize(x) for x in obj] + return obj def _encode_item(item: Any) -> Optional[Any]: if item is None: @@ -678,24 +682,26 @@ def _encode_item(item: Any) -> Optional[Any]: if isinstance(item, (int, float, str)): return item if isinstance(item, (set, frozenset)): - return json.dumps(sorted(item, key=str), default=_json_default, separators=(",", ":")) + return json.dumps([_normalize(x) for x in sorted(item, key=str)], default=str, separators=(",", ":")) if isinstance(item, (dict, list, tuple)): - return json.dumps(item, default=_json_default, separators=(",", ":")) + return json.dumps(_normalize(item), default=str, separators=(",", ":")) return str(item) result: Dict[str, Any] = {} for k, v in params.items(): if v is None: continue + key_str = str(k) if isinstance(v, (list, tuple)): encoded_items = [_encode_item(sub) for sub in v if sub is not None] - result[k] = encoded_items + result[key_str] = encoded_items else: encoded_val = _encode_item(v) if encoded_val is not None: - result[k] = encoded_val + result[key_str] = encoded_val return result + diff --git a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py index cdfdeb9..9b01bf3 100644 --- a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py +++ b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py @@ -908,7 +908,7 @@ async def test_call_tool_encodes_object_and_list_query_parameters(http_transport http_method="GET", ) tool_args = { - "filter": {"status": "open", "nested": {"key": 1}, "tags": {"python", "async"}}, + "filter": {"status": "open", "nested": {100: "numeric_key"}, "tags": {"python", "async"}}, "tags": ["alpha", "beta"], "active": True, "count": 42, @@ -917,7 +917,7 @@ async def test_call_tool_encodes_object_and_list_query_parameters(http_transport "optional_unset": None, } result = await http_transport.call_tool(None, "echo_tool", tool_args, call_template) - assert result["query"]["filter"] == '{"status":"open","nested":{"key":1},"tags":["async","python"]}' + assert result["query"]["filter"] == '{"status":"open","nested":{"100":"numeric_key"},"tags":["async","python"]}' assert result["query"]["active"] == "true" assert result["query"]["count"] == "42" assert result["query"]["categories"] == '["books","electronics"]' @@ -928,3 +928,4 @@ async def test_call_tool_encodes_object_and_list_query_parameters(http_transport + From 95fbe9a58e154874e10b90451779639292ea927e Mon Sep 17 00:00:00 2001 From: Sooriya04 Date: Wed, 4 Nov 2026 23:57:12 +0530 Subject: [PATCH 6/6] fix(http): detect and reject dictionary key collisions during query param normalization - Detects when two distinct dictionary keys map to the same string representation (e.g. 100 and '100') and raises a descriptive ValueError rather than silently overwriting and losing data. - Adds regression unit test verifying collision detection. --- .../http/src/utcp_http/_security.py | 10 +++++++++- .../tests/test_http_communication_protocol.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/plugins/communication_protocols/http/src/utcp_http/_security.py b/plugins/communication_protocols/http/src/utcp_http/_security.py index 7500d1f..fa70578 100644 --- a/plugins/communication_protocols/http/src/utcp_http/_security.py +++ b/plugins/communication_protocols/http/src/utcp_http/_security.py @@ -667,7 +667,15 @@ def encode_query_params(params: Dict[str, Any]) -> Dict[str, Any]: """ def _normalize(obj: Any) -> Any: if isinstance(obj, dict): - return {str(k): _normalize(v) for k, v in obj.items()} + normalized: Dict[str, Any] = {} + for k, v in obj.items(): + key_str = str(k) + if key_str in normalized: + raise ValueError( + f"Query parameter dictionary key collision: conflicting key {k!r} maps to existing {key_str!r}" + ) + normalized[key_str] = _normalize(v) + return normalized if isinstance(obj, (set, frozenset)): return [_normalize(x) for x in sorted(obj, key=str)] if isinstance(obj, (list, tuple)): diff --git a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py index 9b01bf3..c9e7869 100644 --- a/plugins/communication_protocols/http/tests/test_http_communication_protocol.py +++ b/plugins/communication_protocols/http/tests/test_http_communication_protocol.py @@ -926,6 +926,23 @@ async def test_call_tool_encodes_object_and_list_query_parameters(http_transport assert result["multi_query"]["matrix"] == ["[1,2]", "[3,4]"] +@pytest.mark.asyncio +async def test_call_tool_detects_nested_dictionary_key_collision(http_transport, aiohttp_client, app): + """Verify that distinct dictionary keys that map to the same string raise ValueError instead of silently overwriting.""" + client = await aiohttp_client(app) + call_template = HttpCallTemplate( + name="echo_tool", + url=f"http://localhost:{client.port}/echo-query", + http_method="GET", + ) + tool_args = { + "filter": {100: "first", "100": "second"} + } + with pytest.raises(ValueError, match="Query parameter dictionary key collision"): + await http_transport.call_tool(None, "echo_tool", tool_args, call_template) + + +