Conversation
call_tool_streaming failed for several protocols instead of emitting the full result as a single chunk like the HTTP protocol does: - CLI raised NotImplementedError on every streaming call. - MCP yielded the un-awaited coroutine instead of the result. - TCP was a plain coroutine returning a generator, so the client's `async for` failed. UDP gets the same shape and type annotation. SSE improvements: - Implement `reconnect` / `retry_timeout`, which were accepted but never acted on. When an established stream drops, reconnect after `retry_timeout` (or the server's `retry:` value) with `Last-Event-ID`, capped at MAX_RECONNECT_ATTEMPTS per call. A clean end of stream completes the call; connection or HTTP errors on the initial request still fail immediately. Redirects stay refused on every attempt. - Handle CRLF line endings and a trailing unterminated event. Tests added for every fixed protocol and for the SSE reconnect paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mcp 2.x removed `mcp.server.fastmcp.FastMCP` and `mcp.shared.exceptions.McpError`, which the MCP test mocks import. CI installs the newest mcp, so every job failed at collection with mcp 2.1.1 (the last green run on dev predates the mcp 2 release). The plugin targets the 1.x API; a fresh install with the pin resolves to mcp 1.29.1 and the MCP suite passes. Migrating to mcp 2 is a separate task. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nwrap Python counterpart of typescript-utcp PR #33 plus its follow-ups, so both SDKs behave the same in the next release. - Stdio MCP children no longer inherit the host's stderr. mcp-use's MCPClient.from_dict offers no way to set the errlog that StdioConnector hands to the SDK's stdio_client, so a thin MCPClient subclass sets the connector's errlog to os.devnull between construction and initialization. UTCP_MCP_CHILD_STDERR=inherit restores the old behavior for debugging (same switch as the TypeScript SDK), and a stdio server that fails to start logs a hint pointing at it. - structuredContent is used when it is not None (the previous hasattr check was always true on CallToolResult). A FastMCP-style single-key {"result": value} wrapper is unwrapped; an object that merely has a "result" key among others is a genuine object return and now passes through untouched instead of losing its sibling keys. - Tests for both, plus a README section on child process stderr. Circular $ref handling from #33 needs no port: this plugin passes MCP schemas through without dereferencing them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mcp 2.x removed `mcp.server.fastmcp.FastMCP` and `mcp.shared.exceptions.McpError`, which the MCP test mocks import. CI installs the newest mcp, so every job failed at collection with mcp 2.1.1 (the last green run on dev predates the mcp 2 release). The plugin targets the 1.x API; a fresh install with the pin resolves to mcp 1.29.1 and the MCP suite passes. Migrating to mcp 2 is a separate task. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
FastMCP wraps only non-object returns as {"result": value}, so a
single-key {"result": {...}} is a genuine object return and must keep its
shape. Unwrap only when the inner value is not a dict. Tests added for the
list wrapper and the genuine single-key object return. Mirrors the cubic
review fix on typescript-utcp#42.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ys, fix CRLF framing Addresses cubic review on #100: - The handshake (until response headers arrive) is bounded by HANDSHAKE_TIMEOUT_SECONDS (30 s) via asyncio.wait_for, so a server that accepts the connection but never answers cannot hang the call. Reading the body stays unbounded: an SSE stream may legitimately be quiet. - A reconnect handshake that fails (refused, 503, timeout) now counts as one attempt and is retried; only the initial handshake fails fast. - The reconnect delay is capped at MAX_RECONNECT_DELAY_MS (60 s) whatever retry_timeout or a server-sent retry: field asks for, so the attempt cap actually bounds the total wait. - A CRLF split across two chunks no longer becomes two LFs and ends the event early: a trailing CR is held until the next chunk. Decoding is now incremental too, so a multi-byte UTF-8 character straddling chunks no longer raises. - An event that exceeds MAX_EVENT_BUFFER_CHARS (16 Mi) without a blank-line delimiter raises SseProtocolError, which is never retried. Tests for each of the above. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Count connections in the no-delimiter handler and assert exactly one, so the test actually verifies that SseProtocolError bypasses the reconnect path instead of relying on the error being re-raised on a retry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…aming-single-chunk-and-sse-reconnect Fix streaming mode for CLI, MCP, TCP protocols and add SSE reconnection
Both #100 and this branch appended tests to test_mcp_transport.py; keep both.
…quiet-child-stderr mcp: quiet stdio child stderr by default, tighten structuredContent unwrap
Python counterpart of typescript-utcp #26 / #44. raise_for_status() raises a ClientResponseError whose message is only the reason phrase ("Forbidden"); the body, where servers put the real reason ({"error": "..."}), was discarded, so a refused call or discovery surfaced as nothing more than a status code. New utcp_http._errors.raise_for_status_with_body reads the body on a 4xx/5xx and raises a ClientResponseError of the same status and headers whose message is "<reason>: <detail>", where detail is a string error / message / detail field when the body is JSON, otherwise the raw body (truncated). The raw text is attached as .body. Used by the HTTP protocol's tool calls and discovery and by SSE and Streamable HTTP discovery. The exception type is unchanged, so existing handlers keep working. Tests: body in the call error, object-valued error field shows its structure, and discovery errors[] carries the body for all three protocols. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ounded Addresses cubic review on #102: - error_detail_from_body no longer skips an object-valued `error` to reach a lower-priority generic string: the first of error / message / detail that is present decides, and a structured value returns the raw JSON so its shape stays visible. Null and blank strings are still skipped. - The error body is read incrementally and capped at MAX_BODY_READ_BYTES (64 KiB) instead of buffered in full and truncated afterwards, so an arbitrarily large 4xx/5xx body from an untrusted endpoint cannot grow memory unbounded. Decoding is lenient and honours the response charset. Tests for precedence, null/blank skipping, and a 1 MiB error body. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d detail - Decoding a bounded error body now falls back to UTF-8 when the response declares an unknown charset (LookupError) or the lookup fails for any other reason, so the detail is never lost. Tests for a body without a Content-Type and one with an unknown charset. - The precedence test asserts on the parsed detail instead of slicing the message string. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-surface-error-body http: surface the server's error body on failed calls and discovery
… streaming calls Pre-release review of dev: - mcp: _ensure_mcp_client compared the client's whole config dict with its mcpServers entry, which was always unequal, so every tool call built a new MCPClient and spawned a fresh server process that was never closed. Compare the mcpServers entry, and close the previous client's sessions when the configuration really changes. Test asserts one client and one session across two calls. - sse / streamable_http: the streaming call paths now raise with the server's error body like discovery and the TypeScript SDK already do. - _errors: decode the bounded body once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 17 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="plugins/communication_protocols/http/tests/test_sse_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/http/tests/test_sse_communication_protocol.py:154">
P3: slow_handshake_handler sleeps a hard-coded 5s, but test_initial_handshake_timeout_raises patches HANDSHAKE_TIMEOUT_SECONDS to 0.3, so the client aborts the handshake at ~0.3s while the server-side handler coroutine keeps sleeping ~4.7s longer. After the test returns the handler is still pending on the event loop, and when it wakes it writes a 204 to a client that already disconnected. This leaves a dangling task that can emit "Task was destroyed but it is pending" noise and slow teardown. Reduce the sleep so it finishes near the timeout, or sleep until the transport is closed instead of a fixed 5s.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| async def slow_handshake_handler(request): | ||
| """Accepts the connection but does not send response headers for a long time.""" | ||
| await asyncio.sleep(5) |
There was a problem hiding this comment.
P3: slow_handshake_handler sleeps a hard-coded 5s, but test_initial_handshake_timeout_raises patches HANDSHAKE_TIMEOUT_SECONDS to 0.3, so the client aborts the handshake at ~0.3s while the server-side handler coroutine keeps sleeping ~4.7s longer. After the test returns the handler is still pending on the event loop, and when it wakes it writes a 204 to a client that already disconnected. This leaves a dangling task that can emit "Task was destroyed but it is pending" noise and slow teardown. Reduce the sleep so it finishes near the timeout, or sleep until the transport is closed instead of a fixed 5s.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/tests/test_sse_communication_protocol.py, line 154:
<comment>slow_handshake_handler sleeps a hard-coded 5s, but test_initial_handshake_timeout_raises patches HANDSHAKE_TIMEOUT_SECONDS to 0.3, so the client aborts the handshake at ~0.3s while the server-side handler coroutine keeps sleeping ~4.7s longer. After the test returns the handler is still pending on the event loop, and when it wakes it writes a 204 to a client that already disconnected. This leaves a dangling task that can emit "Task was destroyed but it is pending" noise and slow teardown. Reduce the sleep so it finishes near the timeout, or sleep until the transport is closed instead of a fixed 5s.</comment>
<file context>
@@ -105,6 +105,91 @@ async def token_header_auth_handler(request):
+
+async def slow_handshake_handler(request):
+ """Accepts the connection but does not send response headers for a long time."""
+ await asyncio.sleep(5)
+ return web.Response(status=204)
+
</file context>
- sse: an event block without an `event:` field has the type "message" (spec), so event_type="message" now matches it. An empty `id:` resets the last event ID and no Last-Event-ID header is sent for it; ids containing NUL are ignored. A 200 whose Content-Type is not text/event-stream raises SseProtocolError instead of parsing into zero events. Calls that send a request body are never reconnected: a re-issued POST could re-execute a non-idempotent tool. - _errors: a body nested deeper than the JSON parser's recursion limit raised RecursionError past the HTTP error handling; it is caught and treated as text. Control characters are collapsed so a server cannot forge log records or terminal escapes through an error message. - mcp: close() referenced a _session_locks attribute that was never assigned and always raised AttributeError after cleanup. A child that starts but fails the MCP handshake is now disconnected and removed from active_sessions instead of lingering. Tests for each. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- sse: `retry:` is honoured only when made of ASCII digits (spec); "-1" or "20ms" no longer change the reconnect delay. A stream that ends in the middle of an event no longer dispatches the incomplete event (spec: pending data is discarded at end of file). - streamable_http: Content-Type is matched case-insensitively. - udp: remove the stale comment block describing the bug this release fixed. - tests: the slow-handshake handler no longer outlives the test by seconds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Disposition of the 7 findings, all handled in #104 (targets dev, so this PR picks them up once it merges):
#104 also carries fixes from my own review: the MCP client is reused across calls instead of spawning a new server process per call (pre-existing), |
A substring check let a Content-Type such as text/event-stream-invalid through; compare the media-type portion exactly, parameters allowed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- mcp: one MCPClient per distinct server configuration instead of a single shared client. The protocol object is registered once per process and shared by every manual, so a single client made manuals with different configurations evict each other's sessions, including ones still in use by a concurrent call. Clients are keyed by the canonical configuration, created under a lock so two concurrent first calls cannot each spawn a server, and never evicted by another manual's activity. close() closes every client's sessions and keeps a client whose shutdown failed so a later close() can retry it. - sse: the error-body read on a refused stream is bounded by the handshake timeout, so a server that answers 4xx/5xx and then stalls cannot hang the call. A final blank line ending in a lone CR still completes the last event; only genuinely incomplete events are discarded. Absurdly long retry digit strings are ignored. - _errors: control-character collapsing covers the C1 range too. - tests: streaming calls against a 5xx assert the body is surfaced for both SSE and Streamable HTTP; separate-clients-per-configuration test; the malformed-retry test is named for what it verifies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- mcp: when a manual's configuration changes and no other manual uses the old one, the old client's sessions are closed instead of lingering until close(). Ownership is tracked per manual name. - sse: the residual buffer is checked against the event cap at end of stream too; retry digit strings are bounded to 18 digits before conversion. - tests: the connection-drop handlers wait 100 ms after writing the first event before closing the socket. On Windows CI the event and the close otherwise arrived together and aiohttp raised before delivering the event, failing six tests that pass elsewhere. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- mcp: deregistering a manual drops only that manual's claim on its client; the client's sessions are closed when no manual references the configuration any more. Two manuals with identical configurations share one client, and deregistering one no longer tears down the other's sessions or leaves a ghost ownership entry. - tests: the streaming error-body tests use a body distinct from the reason phrase so they can only pass when the body is surfaced; deregistration test for shared configurations. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…me alone The protocol object is a process-wide singleton, so two UtcpClient instances may register a manual of the same name with different configurations; keyed by name alone, the second registration overwrote the first's ownership entry and closed its client. The calling client's identity is now part of the owner key, carried through the public entry points with a context variable rather than threading `caller` through every helper. Test added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Restoring a failed client into the live map happened outside the lock and could overwrite a newer client for the same configuration. Failed clients now go to a separate list that close() retries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…polish Pre-release fixes: reuse the MCP client across calls; error bodies on streaming calls
ensure_secure_url permits loopback HTTP for local development, which left hand-written UTCP manuals able to declare tool URLs on the agent's own loopback interface even when discovered from a remote origin. The OpenAPI converter already enforces this rule for specs it converts; apply the same check to native UTCP manuals in the http, sse and streamable_http protocols via a shared reject_remote_loopback_tool_urls helper. Manuals discovered from loopback (local dev) stay exempt. Adds unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Manual-level OAuth2 was accepted on the MCP call template but never used, so an auth block had no effect. Fetch the token and inject it as the connection's bearer credential for HTTP servers that don't already carry their own. Validate the OAuth2 token endpoint before sending credentials to it, and validate HTTP/WS server URLs before dialing, matching the trust boundary the HTTP-family plugins enforce. Key clients by auth as well as server config so manuals with distinct credentials don't share a client or token. Adds unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
9 issues found across 7 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py:281">
P2: When a manual has OAuth2 metadata but no server needs the manual bearer token, registration still contacts the token endpoint and can fail before starting an otherwise valid stdio server. Fetch the token only when a URL server lacks `auth_token` and an `Authorization` header.</violation>
<violation number="2" location="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py:284">
P1: When two manuals reuse a `client_id` with different token endpoints or secrets, this new path sends the first manual's cached bearer token to the second server. The same cache returns expiring tokens forever, so calls fail after token expiry; scope the cache by issuer/credentials and refresh tokens before reuse.</violation>
<violation number="3" location="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py:297">
P2: When a server config uses mcp-use's supported `auth` credential, this guard still injects the manual token because it checks only `auth_token` and `headers`. Preserve the server's `auth` value before injecting manual OAuth credentials.</violation>
</file>
<file name="plugins/communication_protocols/http/src/utcp_http/_security.py">
<violation number="1" location="plugins/communication_protocols/http/src/utcp_http/_security.py:500">
P2: When a loopback discovery URL redirects to a remote manual, this exemption trusts the initial URL rather than the origin that supplied the manual. Track the final response URL through discovery and apply the loopback check to that URL, or reject cross-origin redirects from loopback discovery.</violation>
<violation number="2" location="plugins/communication_protocols/http/src/utcp_http/_security.py:505">
P1: This misses resolver-valid loopback aliases such as `https://127.1/...`: `is_loopback_url` returns false, while the invocation's HTTPS check allows the request and the resolver maps the host to `127.0.0.1`. Canonicalize numeric host forms or resolve and reject loopback destinations before allowing a remote manual.</violation>
<violation number="3" location="plugins/communication_protocols/http/src/utcp_http/_security.py:505">
P1: A remote manual can bypass this check with a templated authority such as `https://{host}/...`: the check runs before `{host}` is resolved, then the invocation path substitutes `127.0.0.1` and `ensure_secure_url` permits it. Reject dynamic hosts for remote manuals or retain the manual's remote trust state and repeat this check after URL substitution.</violation>
</file>
<file name="plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py:177">
P1: When a loopback discovery URL redirects to a remote origin, this passes the original loopback URL, so the remote manual is exempted and can target loopback tools. Classify the manual using the final response origin instead.</violation>
</file>
<file name="plugins/communication_protocols/mcp/tests/test_mcp_oauth_security.py">
<violation number="1" location="plugins/communication_protocols/mcp/tests/test_mcp_oauth_security.py:41">
P2: This test performs real OAuth requests and can wait on connection timeouts or fail if port 1 is occupied; it also accepts any exception unrelated to URL validation. Seed the protocol’s token cache or mock the HTTP session, then assert the cached result so the secure-URL guard is tested without network I/O.</violation>
</file>
<file name="plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py:229">
P1: When a loopback discovery URL redirects to a remote manual, this call still classifies the response by the original loopback URL. The helper then exempts the manual, so its remote tool definitions can target loopback services; track whether any discovery redirect left the loopback origin before granting the exemption.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| if isinstance(manual_call_template.auth, OAuth2Auth): | ||
| # Fetches (and validates the token endpoint of) the manual's OAuth2 | ||
| # credentials before any server connection is dialed. | ||
| token = await self._handle_oauth2(manual_call_template.auth) |
There was a problem hiding this comment.
P1: When two manuals reuse a client_id with different token endpoints or secrets, this new path sends the first manual's cached bearer token to the second server. The same cache returns expiring tokens forever, so calls fail after token expiry; scope the cache by issuer/credentials and refresh tokens before reuse.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py, line 284:
<comment>When two manuals reuse a `client_id` with different token endpoints or secrets, this new path sends the first manual's cached bearer token to the second server. The same cache returns expiring tokens forever, so calls fail after token expiry; scope the cache by issuer/credentials and refresh tokens before reuse.</comment>
<file context>
@@ -187,6 +267,37 @@ async def _ensure_mcp_client(self, manual_call_template: 'McpCallTemplate') -> M
+ if isinstance(manual_call_template.auth, OAuth2Auth):
+ # Fetches (and validates the token endpoint of) the manual's OAuth2
+ # credentials before any server connection is dialed.
+ token = await self._handle_oauth2(manual_call_template.auth)
+ for server_name, server_config in servers.items():
+ if not isinstance(server_config, dict):
</file context>
| for tool in getattr(manual, "tools", None) or []: | ||
| call_template = getattr(tool, "tool_call_template", None) | ||
| url = getattr(call_template, "url", None) | ||
| if isinstance(url, str) and is_loopback_url(url): |
There was a problem hiding this comment.
P1: This misses resolver-valid loopback aliases such as https://127.1/...: is_loopback_url returns false, while the invocation's HTTPS check allows the request and the resolver maps the host to 127.0.0.1. Canonicalize numeric host forms or resolve and reject loopback destinations before allowing a remote manual.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/_security.py, line 505:
<comment>This misses resolver-valid loopback aliases such as `https://127.1/...`: `is_loopback_url` returns false, while the invocation's HTTPS check allows the request and the resolver maps the host to `127.0.0.1`. Canonicalize numeric host forms or resolve and reject loopback destinations before allowing a remote manual.</comment>
<file context>
@@ -478,3 +478,35 @@ async def safe_request_with_redirects(
+ for tool in getattr(manual, "tools", None) or []:
+ call_template = getattr(tool, "tool_call_template", None)
+ url = getattr(call_template, "url", None)
+ if isinstance(url, str) and is_loopback_url(url):
+ raise ValueError(
+ f"Security error during {context}: a manual fetched from "
</file context>
| for tool in getattr(manual, "tools", None) or []: | ||
| call_template = getattr(tool, "tool_call_template", None) | ||
| url = getattr(call_template, "url", None) | ||
| if isinstance(url, str) and is_loopback_url(url): |
There was a problem hiding this comment.
P1: A remote manual can bypass this check with a templated authority such as https://{host}/...: the check runs before {host} is resolved, then the invocation path substitutes 127.0.0.1 and ensure_secure_url permits it. Reject dynamic hosts for remote manuals or retain the manual's remote trust state and repeat this check after URL substitution.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/_security.py, line 505:
<comment>A remote manual can bypass this check with a templated authority such as `https://{host}/...`: the check runs before `{host}` is resolved, then the invocation path substitutes `127.0.0.1` and `ensure_secure_url` permits it. Reject dynamic hosts for remote manuals or retain the manual's remote trust state and repeat this check after URL substitution.</comment>
<file context>
@@ -478,3 +478,35 @@ async def safe_request_with_redirects(
+ for tool in getattr(manual, "tools", None) or []:
+ call_template = getattr(tool, "tool_call_template", None)
+ url = getattr(call_template, "url", None)
+ if isinstance(url, str) and is_loopback_url(url):
+ raise ValueError(
+ f"Security error during {context}: a manual fetched from "
</file context>
| await raise_for_status_with_body(response) | ||
| response_data = await response.json() | ||
| utcp_manual = UtcpManualSerializer().validate_dict(response_data) | ||
| reject_remote_loopback_tool_urls(url, utcp_manual) |
There was a problem hiding this comment.
P1: When a loopback discovery URL redirects to a remote origin, this passes the original loopback URL, so the remote manual is exempted and can target loopback tools. Classify the manual using the final response origin instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py, line 177:
<comment>When a loopback discovery URL redirects to a remote origin, this passes the original loopback URL, so the remote manual is exempted and can target loopback tools. Classify the manual using the final response origin instead.</comment>
<file context>
@@ -174,6 +174,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
await raise_for_status_with_body(response)
response_data = await response.json()
utcp_manual = UtcpManualSerializer().validate_dict(response_data)
+ reject_remote_loopback_tool_urls(url, utcp_manual)
return RegisterManualResult(
success=True,
</file context>
| reject_remote_loopback_tool_urls(url, utcp_manual) | |
| reject_remote_loopback_tool_urls(str(response.url), utcp_manual) |
| if "utcp_version" in response_data and "tools" in response_data: | ||
| logger.info(f"Detected UTCP manual from '{manual_call_template.name}'.") | ||
| utcp_manual = UtcpManualSerializer().validate_dict(response_data) | ||
| reject_remote_loopback_tool_urls(manual_call_template.url, utcp_manual) |
There was a problem hiding this comment.
P1: When a loopback discovery URL redirects to a remote manual, this call still classifies the response by the original loopback URL. The helper then exempts the manual, so its remote tool definitions can target loopback services; track whether any discovery redirect left the loopback origin before granting the exemption.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py, line 229:
<comment>When a loopback discovery URL redirects to a remote manual, this call still classifies the response by the original loopback URL. The helper then exempts the manual, so its remote tool definitions can target loopback services; track whether any discovery redirect left the loopback origin before granting the exemption.</comment>
<file context>
@@ -226,6 +226,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
if "utcp_version" in response_data and "tools" in response_data:
logger.info(f"Detected UTCP manual from '{manual_call_template.name}'.")
utcp_manual = UtcpManualSerializer().validate_dict(response_data)
+ reject_remote_loopback_tool_urls(manual_call_template.url, utcp_manual)
else:
logger.info(f"Assuming OpenAPI spec from '{manual_call_template.name}'. Converting to UTCP manual.")
</file context>
| if isinstance(manual_call_template.auth, OAuth2Auth): | ||
| # Fetches (and validates the token endpoint of) the manual's OAuth2 | ||
| # credentials before any server connection is dialed. | ||
| token = await self._handle_oauth2(manual_call_template.auth) |
There was a problem hiding this comment.
P2: When a manual has OAuth2 metadata but no server needs the manual bearer token, registration still contacts the token endpoint and can fail before starting an otherwise valid stdio server. Fetch the token only when a URL server lacks auth_token and an Authorization header.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py, line 281:
<comment>When a manual has OAuth2 metadata but no server needs the manual bearer token, registration still contacts the token endpoint and can fail before starting an otherwise valid stdio server. Fetch the token only when a URL server lacks `auth_token` and an `Authorization` header.</comment>
<file context>
@@ -187,6 +267,37 @@ async def _ensure_mcp_client(self, manual_call_template: 'McpCallTemplate') -> M
+ """
+ servers = copy.deepcopy(manual_call_template.config.mcpServers)
+ token: Optional[str] = None
+ if isinstance(manual_call_template.auth, OAuth2Auth):
+ # Fetches (and validates the token endpoint of) the manual's OAuth2
+ # credentials before any server connection is dialed.
</file context>
| if isinstance(manual_call_template.auth, OAuth2Auth): | |
| # Fetches (and validates the token endpoint of) the manual's OAuth2 | |
| # credentials before any server connection is dialed. | |
| token = await self._handle_oauth2(manual_call_template.auth) | |
| if ( | |
| isinstance(manual_call_template.auth, OAuth2Auth) | |
| and any( | |
| isinstance(server_config, dict) | |
| and "url" in server_config | |
| and not server_config.get("auth_token") | |
| and not _has_authorization_header(server_config) | |
| for server_config in servers.values() | |
| ) | |
| ): | |
| token = await self._handle_oauth2(manual_call_template.auth) |
| tool's call-template URL. A manual fetched from loopback (local dev) is | ||
| exempt, exactly as the converter exempts a local spec. | ||
| """ | ||
| if is_loopback_url(discovery_url): |
There was a problem hiding this comment.
P2: When a loopback discovery URL redirects to a remote manual, this exemption trusts the initial URL rather than the origin that supplied the manual. Track the final response URL through discovery and apply the loopback check to that URL, or reject cross-origin redirects from loopback discovery.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/_security.py, line 500:
<comment>When a loopback discovery URL redirects to a remote manual, this exemption trusts the initial URL rather than the origin that supplied the manual. Track the final response URL through discovery and apply the loopback check to that URL, or reject cross-origin redirects from loopback discovery.</comment>
<file context>
@@ -478,3 +478,35 @@ async def safe_request_with_redirects(
+ tool's call-template URL. A manual fetched from loopback (local dev) is
+ exempt, exactly as the converter exempts a local spec.
+ """
+ if is_loopback_url(discovery_url):
+ return
+ for tool in getattr(manual, "tools", None) or []:
</file context>
| proto = McpCommunicationProtocol() | ||
| # Validation passes for a loopback token URL; the fetch then fails with a | ||
| # connection error, which must NOT be the security-guard message. | ||
| with pytest.raises(Exception) as excinfo: |
There was a problem hiding this comment.
P2: This test performs real OAuth requests and can wait on connection timeouts or fail if port 1 is occupied; it also accepts any exception unrelated to URL validation. Seed the protocol’s token cache or mock the HTTP session, then assert the cached result so the secure-URL guard is tested without network I/O.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/mcp/tests/test_mcp_oauth_security.py, line 41:
<comment>This test performs real OAuth requests and can wait on connection timeouts or fail if port 1 is occupied; it also accepts any exception unrelated to URL validation. Seed the protocol’s token cache or mock the HTTP session, then assert the cached result so the secure-URL guard is tested without network I/O.</comment>
<file context>
@@ -0,0 +1,108 @@
+ proto = McpCommunicationProtocol()
+ # Validation passes for a loopback token URL; the fetch then fails with a
+ # connection error, which must NOT be the security-guard message.
+ with pytest.raises(Exception) as excinfo:
+ await proto._handle_oauth2(_oauth("http://127.0.0.1:1/token"))
+ assert "Security error" not in str(excinfo.value)
</file context>
| # already carry their own credentials. mcp-use turns ``auth_token`` | ||
| # into an ``Authorization: Bearer`` header on the connection. | ||
| if token is not None and "url" in server_config: | ||
| if not server_config.get("auth_token") and not _has_authorization_header(server_config): |
There was a problem hiding this comment.
P2: When a server config uses mcp-use's supported auth credential, this guard still injects the manual token because it checks only auth_token and headers. Preserve the server's auth value before injecting manual OAuth credentials.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py, line 297:
<comment>When a server config uses mcp-use's supported `auth` credential, this guard still injects the manual token because it checks only `auth_token` and `headers`. Preserve the server's `auth` value before injecting manual OAuth credentials.</comment>
<file context>
@@ -187,6 +267,37 @@ async def _ensure_mcp_client(self, manual_call_template: 'McpCallTemplate') -> M
+ # already carry their own credentials. mcp-use turns ``auth_token``
+ # into an ``Authorization: Bearer`` header on the connection.
+ if token is not None and "url" in server_config:
+ if not server_config.get("auth_token") and not _has_authorization_header(server_config):
+ server_config["auth_token"] = token
+ return servers
</file context>
| if not server_config.get("auth_token") and not _has_authorization_header(server_config): | |
| if not server_config.get("auth_token") and not server_config.get("auth") and not _has_authorization_header(server_config): |
Summary by cubic
Turns protocol streaming and discovery paths into reliable, bounded operations instead of leaving unsupported generators, unbounded reconnects, or opaque failures. It also tightens endpoint and MCP authentication checks while preserving local loopback development.
Bug Fixes
Last-Event-ID, retries at most 5 times with 60-second delays, and bounds handshakes to 30 seconds; initial failures still fail fast and POST streams are not replayed.error/message/detailbodies.Dependencies
utcp_mcppinsmcpto<2becausemcp2 removed APIs the plugin uses.UTCP_MCP_CHILD_STDERR=inheritto restore it while debugging.Written for commit de9ef55. Summary will update on new commits.