Skip to content

Commit e320744

Browse files
authored
Merge pull request #103 from universal-tool-calling-protocol/dev
Dev
2 parents fd30247 + 961e246 commit e320744

24 files changed

Lines changed: 2182 additions & 173 deletions

‎plugins/communication_protocols/cli/pyproject.toml‎

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

55
[project]
66
name = "utcp-cli"
7-
version = "1.1.4"
7+
version = "1.1.5"
88
authors = [
99
{ name = "UTCP Contributors" },
1010
]

‎plugins/communication_protocols/cli/src/utcp_cli/cli_communication_protocol.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -972,9 +972,19 @@ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], too
972972

973973
async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]:
974974
"""REQUIRED
975-
Streaming calls are not supported for the CLI protocol.
975+
Execute a tool call through the CLI transport streamingly.
976976
977-
Raises:
978-
NotImplementedError: Always, as this functionality is not supported.
977+
The CLI protocol does not natively support streaming, so the command is
978+
executed to completion and the full result is yielded as a single chunk.
979+
980+
Args:
981+
caller: The UTCP client that is calling this method.
982+
tool_name: Name of the tool to call.
983+
tool_args: Dictionary of arguments to pass to the tool.
984+
tool_call_template: Call template of the tool to call.
985+
986+
Yields:
987+
The complete tool result as a single item.
979988
"""
980-
raise NotImplementedError("Streaming is not supported by the CLI communication protocol.")
989+
result = await self.call_tool(caller, tool_name, tool_args, tool_call_template)
990+
yield result

‎plugins/communication_protocols/cli/tests/test_cli_communication_protocol.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,22 @@ async def test_call_tool_json_output(transport: CliCommunicationProtocol, mock_c
276276
assert "Echo:" in result["result"] and "Hello" in result["result"]
277277

278278

279+
@pytest.mark.asyncio
280+
async def test_call_tool_streaming_yields_single_chunk(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
281+
"""Streaming mode should emit the full result as one chunk instead of failing."""
282+
call_template = CliCallTemplate(
283+
commands=[
284+
{"command": f"{python_executable} {mock_cli_script} --message UTCP_ARG_message_UTCP_END"}
285+
]
286+
)
287+
288+
chunks = [chunk async for chunk in transport.call_tool_streaming(None, "echo", {"message": "Hello World"}, call_template)]
289+
290+
assert len(chunks) == 1
291+
assert isinstance(chunks[0], dict)
292+
assert "Echo:" in chunks[0]["result"] and "Hello" in chunks[0]["result"]
293+
294+
279295
@pytest.mark.asyncio
280296
async def test_call_tool_math_operation(transport: CliCommunicationProtocol, mock_cli_script, python_executable):
281297
"""Test calling a math tool with numeric arguments."""

‎plugins/communication_protocols/http/pyproject.toml‎

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

55
[project]
66
name = "utcp-http"
7-
version = "1.1.11"
7+
version = "1.1.12"
88
authors = [
99
{ name = "UTCP Contributors" },
1010
]
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Surface the server's error body on failed HTTP calls.
2+
3+
``aiohttp.ClientResponse.raise_for_status()`` raises a ``ClientResponseError``
4+
whose ``message`` is only the reason phrase ("Forbidden"). Servers put the
5+
real reason in the response body, typically ``{"error": "..."}``, and that
6+
was discarded, so a refused call or discovery surfaced as nothing more than a
7+
status code. Mirrors the TypeScript SDK's ``_normalizeToolError``.
8+
"""
9+
import json
10+
import re
11+
from typing import Optional
12+
13+
import aiohttp
14+
15+
# Bodies are folded into an exception message; keep pathological ones bounded.
16+
MAX_DETAIL_CHARS = 2000
17+
18+
# How much of an error body is read at all. The response may come from an
19+
# attacker-controlled endpoint (discovery URLs are exactly that trust
20+
# surface), so the read is bounded up front rather than buffered in full and
21+
# truncated afterwards. Comfortably larger than MAX_DETAIL_CHARS so a JSON
22+
# body with a long ``error`` field still parses.
23+
MAX_BODY_READ_BYTES = 64 * 1024
24+
25+
_DETAIL_KEYS = ("error", "message", "detail")
26+
27+
# Control characters (newlines, ANSI escape introducers, NUL) are collapsed so
28+
# server-controlled text folded into an exception message or a log line cannot
29+
# forge extra log records or terminal escape sequences.
30+
# C0 and C1 control ranges: C1 (U+0080..U+009F) carries escape introducers too.
31+
_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f-\x9f]+")
32+
33+
34+
def _clean(text: str) -> str:
35+
return _CONTROL_CHARS.sub(" ", text).strip()[:MAX_DETAIL_CHARS]
36+
37+
38+
def error_detail_from_body(text: str) -> Optional[str]:
39+
"""Extract a human-readable reason from an error response body.
40+
41+
When the body is a JSON object, the first of ``error`` / ``message`` /
42+
``detail`` that is present decides: a non-empty string is returned as the
43+
reason; anything else (an object, a list, a number) means the server sent a
44+
structured error, so the raw JSON is returned to keep that structure
45+
visible rather than skipping ahead to a lower-priority generic string.
46+
A non-JSON body is returned as-is. Returns ``None`` for an empty body.
47+
"""
48+
body = text.strip()
49+
if not body:
50+
return None
51+
try:
52+
data = json.loads(body)
53+
except (ValueError, RecursionError):
54+
# RecursionError: a deeply nested body ("[[[[...") within the read cap
55+
# can exceed the parser's recursion limit; it is still just text.
56+
return _clean(body)
57+
if isinstance(data, dict):
58+
for key in _DETAIL_KEYS:
59+
if key not in data or data[key] is None:
60+
continue
61+
value = data[key]
62+
if isinstance(value, str):
63+
if value.strip():
64+
return _clean(value)
65+
continue
66+
# Structured error: show it rather than a later generic string.
67+
return _clean(body)
68+
return _clean(body)
69+
70+
71+
async def _read_body_bounded(response: aiohttp.ClientResponse, limit: int) -> str:
72+
"""Read at most ``limit`` bytes of the body and decode them leniently."""
73+
chunks = []
74+
total = 0
75+
async for chunk in response.content.iter_chunked(8192):
76+
chunks.append(chunk)
77+
total += len(chunk)
78+
if total >= limit:
79+
break
80+
raw = b"".join(chunks)[:limit]
81+
# ``charset`` only parses the Content-Type header, but stay defensive: the
82+
# body was read directly, so aiohttp's own buffered-body machinery must not
83+
# be relied on, and an unknown charset name must not lose the detail.
84+
try:
85+
return raw.decode(response.charset or "utf-8", errors="replace")
86+
except (LookupError, RuntimeError, ValueError):
87+
return raw.decode("utf-8", errors="replace")
88+
89+
90+
async def raise_for_status_with_body(response: aiohttp.ClientResponse) -> None:
91+
"""Like ``response.raise_for_status()``, but with the response body in the error.
92+
93+
On a 4xx/5xx, reads up to ``MAX_BODY_READ_BYTES`` of the body and raises a
94+
``ClientResponseError`` of the same status and headers whose ``message`` is
95+
``"<reason>: <detail>"``. The text that was read is attached as ``body``
96+
for callers that want the structure. Does nothing on a 2xx/3xx.
97+
"""
98+
if response.status < 400:
99+
return
100+
try:
101+
text = await _read_body_bounded(response, MAX_BODY_READ_BYTES)
102+
except Exception:
103+
text = ""
104+
detail = error_detail_from_body(text)
105+
reason = response.reason or ""
106+
message = f"{reason}: {detail}" if detail else reason
107+
error = aiohttp.ClientResponseError(
108+
response.request_info,
109+
response.history,
110+
status=response.status,
111+
message=message,
112+
headers=response.headers,
113+
)
114+
error.body = text # type: ignore[attr-defined]
115+
raise error

‎plugins/communication_protocols/http/src/utcp_http/_security.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,3 +478,40 @@ async def safe_request_with_redirects(
478478
finally:
479479
if final_response is not None:
480480
final_response.release()
481+
482+
483+
def reject_remote_loopback_tool_urls(
484+
discovery_url: str, manual: Any, *, context: str = "manual discovery"
485+
) -> None:
486+
"""Reject a remotely-discovered manual that points tool calls at loopback.
487+
488+
``ensure_secure_url`` deliberately permits loopback HTTP so local
489+
development works. That leaves one gap: a manual fetched from a remote
490+
(non-loopback) origin can still declare tool URLs on the agent's own
491+
loopback interface, turning tool invocation into a request against a
492+
service that only trusts local callers.
493+
494+
The OpenAPI converter already closes this for specs it converts (a remote
495+
spec may not declare a loopback ``servers[0].url``). Hand-written UTCP
496+
manuals bypass the converter, so the same rule is applied here to every
497+
tool's call-template URL. A manual fetched from loopback (local dev) is
498+
exempt, exactly as the converter exempts a local spec.
499+
500+
``discovery_url`` must be the *final* response URL after any redirects, not
501+
the URL originally requested: a loopback discovery URL that redirects to a
502+
remote origin is serving a remote manual and must not keep the local-dev
503+
exemption.
504+
"""
505+
if is_loopback_url(discovery_url):
506+
return
507+
for tool in getattr(manual, "tools", None) or []:
508+
call_template = getattr(tool, "tool_call_template", None)
509+
url = getattr(call_template, "url", None)
510+
if isinstance(url, str) and is_loopback_url(url):
511+
raise ValueError(
512+
f"Security error during {context}: a manual fetched from "
513+
f"{discovery_url!r} declares a loopback tool URL ({url!r}) for "
514+
f"tool {getattr(tool, 'name', '?')!r}. A remote manual is not "
515+
"allowed to redirect tool calls at the agent's own loopback "
516+
"interface."
517+
)

‎plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
from utcp_http.http_call_template import HttpCallTemplate
3434
from aiohttp import ClientSession, BasicAuth as AiohttpBasicAuth
3535
from utcp_http.openapi_converter import OpenApiConverter
36-
from utcp_http._security import ensure_secure_url, safe_request_with_redirects
36+
from utcp_http._security import ensure_secure_url, safe_request_with_redirects, reject_remote_loopback_tool_urls
37+
from utcp_http._errors import raise_for_status_with_body
3738
import logging
3839

3940
logging.basicConfig(
@@ -210,7 +211,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
210211
timeout=aiohttp.ClientTimeout(total=10.0),
211212
auth_header_names=auth_header_names,
212213
) as response:
213-
response.raise_for_status() # Raise exception for 4XX/5XX responses
214+
await raise_for_status_with_body(response) # 4XX/5XX, with the server's body in the message
214215

215216
# Check content type to determine how to parse the response
216217
content_type = response.headers.get('Content-Type', '')
@@ -225,6 +226,10 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
225226
if "utcp_version" in response_data and "tools" in response_data:
226227
logger.info(f"Detected UTCP manual from '{manual_call_template.name}'.")
227228
utcp_manual = UtcpManualSerializer().validate_dict(response_data)
229+
# Use the final (post-redirect) URL: a loopback
230+
# discovery URL that redirected to a remote origin is
231+
# serving a remote manual and loses the local-dev exemption.
232+
reject_remote_loopback_tool_urls(str(response.url), utcp_manual)
228233
else:
229234
logger.info(f"Assuming OpenAPI spec from '{manual_call_template.name}'. Converting to UTCP manual.")
230235
converter = OpenApiConverter(response_data, spec_url=manual_call_template.url, call_template_name=manual_call_template.name, auth_tools=manual_call_template.auth_tools)
@@ -359,7 +364,7 @@ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], too
359364
timeout=aiohttp.ClientTimeout(total=30.0),
360365
auth_header_names=auth_header_names,
361366
) as response:
362-
response.raise_for_status()
367+
await raise_for_status_with_body(response)
363368

364369
content_type = response.headers.get('Content-Type', '').lower()
365370
if 'application/json' in content_type:

0 commit comments

Comments
 (0)