Skip to content

Dev - #103

Open
h3xxit wants to merge 26 commits into
mainfrom
dev
Open

Dev#103
h3xxit wants to merge 26 commits into
mainfrom
dev

Conversation

@h3xxit

@h3xxit h3xxit commented Sep 4, 2026

Copy link
Copy Markdown
Member

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

  • CLI now yields completed command results as one chunk; MCP awaits the result; TCP and UDP return proper async generators.
  • MCP reuses one client per server and authentication configuration, tracks ownership per calling client, and closes sessions only when no manual still uses them.
  • MCP manual OAuth2 now supplies bearer credentials when needed; token endpoints and HTTP/WS server URLs are validated before use.
  • SSE resumes dropped streams with 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.
  • SSE parsing handles CRLF and split UTF-8, validates the exact media type, and rejects oversized or malformed frames without reconnecting.
  • Failed HTTP calls, discovery, and streaming paths include bounded server error/message/detail bodies.
  • Remote manuals cannot point tools at loopback services; manuals discovered from loopback remain allowed.

Dependencies

  • utcp_mcp pins mcp to <2 because mcp 2 removed APIs the plugin uses.
  • Stdio MCP child stderr is suppressed by default; set UTCP_MCP_CHILD_STDERR=inherit to restore it while debugging.

Written for commit de9ef55. Summary will update on new commits.

Review in cubic

h3xxit and others added 15 commits September 4, 2026 10:12
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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread plugins/communication_protocols/http/src/utcp_http/_errors.py Outdated
Comment thread plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py Outdated

async def slow_handshake_handler(request):
"""Accepts the connection but does not send response headers for a long time."""
await asyncio.sleep(5)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py Outdated
h3xxit and others added 2 commits September 4, 2026 17:17
- 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>
@h3xxit

h3xxit commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Disposition of the 7 findings, all handled in #104 (targets dev, so this PR picks them up once it merges):

  • Failed initialize leaves the child running: fixed, the session is disconnected and removed from active_sessions.
  • RecursionError from a deeply nested body: fixed.
  • Unterminated trailing SSE event: fixed to match the spec (discarded), with a test.
  • Stale UDP comment: removed.
  • 5 s slow-handshake handler: reduced to 1 s.
  • retry: digits only, empty id:: fixed.

#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), close() no longer raises on a missing attribute (pre-existing), POST streams are never re-issued on reconnect, event_type "message" matches untyped events, non-event-stream 200s fail instead of yielding nothing, and control characters in server error text are collapsed.

h3xxit and others added 9 commits September 4, 2026 17:41
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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment on lines +281 to +284
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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):

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant