Skip to content

Dev - #43

Open
h3xxit wants to merge 27 commits into
mainfrom
dev
Open

Dev#43
h3xxit wants to merge 27 commits into
mainfrom
dev

Conversation

@h3xxit

@h3xxit h3xxit commented Sep 4, 2026

Copy link
Copy Markdown
Member

Summary by cubic

Replaces the SSE and Streamable HTTP placeholder stubs with working transports, and changes CLI callToolStreaming from always throwing to yielding the completed result as one chunk. It also hardens discovery, authentication, reconnection, and error reporting, including protections for remote manuals and MCP token endpoints.

HTTP

  • HTTP, SSE, and Streamable HTTP map path, header, body, and query arguments and support API-key, Basic, and OAuth2 authentication.
  • SSE parses events incrementally, filters by event type, and reconnects dropped GET streams up to five times with Last-Event-ID; body-bearing calls are not retried.
  • Streamable HTTP parses NDJSON and JSON, re-chunks binary responses, and applies one timeout across token fetch, request, and streaming; GET calls with body_field are rejected.
  • Failed calls and discovery preserve status and bounded server response bodies on errors, while query strings stay out of logs.
  • Manuals discovered from remote origins cannot point tools at loopback URLs; local discovery remains allowed.

MCP

  • Tool results prefer structuredContent, retain the legacy structured_output fallback, and unwrap non-object FastMCP {result} wrappers.
  • Recursive JSON Schema references no longer prevent tool discovery.
  • Stdio child stderr is suppressed by default; set UTCP_MCP_CHILD_STDERR=inherit to show it.
  • MCP validates OAuth2 token URLs before sending client credentials.

Written for commit 002ea10. Summary will update on new commits.

Review in cubic

h3xxit and others added 12 commits September 4, 2026 10:08
- CLI: callToolStreaming threw on every call. It now runs the command and
  yields the full result as a single chunk, like the HTTP protocol.

- Streamable HTTP and SSE were registered placeholder stubs that yielded a
  fake string and never contacted the server. Both are now real
  implementations ported from python-utcp: path/header/body/query
  argument mapping, API key / Basic / OAuth2 / OAuth2 user auth (cookies
  are now actually sent), URL re-validation and redirect refusal.

- Streamable HTTP yields per content type: NDJSON lines as parsed
  objects, JSON as one value, everything else as Buffers re-chunked to
  chunk_size. callTool concatenates binary chunks or returns an array.

- SSE parses the wire format incrementally (comments, multi-line data,
  id, retry, CRLF, trailing event), filters by event_type, and implements
  reconnect / retry_timeout: on connection loss it reconnects with
  Last-Event-ID after retry_timeout (or the server's retry value), capped
  at MAX_RECONNECT_ATTEMPTS per call. A clean end of stream completes the
  call; initial connection or HTTP errors fail immediately.

- Streaming fetches pass keepalive: false. Bun's fetch transparently
  re-issues a request when a reused pooled socket dies mid-response,
  which bypassed the reconnect logic and could duplicate data. Node
  ignores the option. Verified against the built package under both
  Node 22 and Bun 1.2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…redContent fallback

Three independent robustness fixes to McpCommunicationProtocol, all battle-
tested for several weeks as a patch-package shim over @utcp/mcp 1.1.1-1.1.3
in a code-mode deployment federating 15+ MCP servers / 400+ tools:

1. $RefParser.dereference(..., { dereference: { circular: 'ignore' } })
   Recursive JSON Schemas (e.g. self-referencing SOQL/SOSL filter grammars
   from Salesforce MCP servers) throw on dereference and take down the whole
   manual's tool discovery. 'ignore' keeps the cycle as a live reference and
   discovery succeeds.

2. StdioClientTransport stderr: default 'ignore', opt-in 'inherit' via
   UTCP_MCP_CHILD_STDERR=inherit. Child MCP servers inherit the host's stderr
   today and flood the terminal during discovery. Deliberately NOT 'pipe':
   with no reader attached the OS pipe buffer fills and a chatty child
   deadlocks.

3. _processMcpToolResult: when a result carries an empty content array but a
   non-null structuredContent (MCP spec field), return structuredContent
   instead of collapsing to [] and silently losing the payload.

Note: the package's dts build step currently fails on an @types/node
resolution issue on a clean checkout of main as well (bun install layout) —
unrelated to this change; the esbuild JS bundle builds clean.
Follow-up to the cherry-picked fix from #33 (itsbrex):

- _processMcpToolResult now returns `structuredContent` whenever the server
  sent it, not only when `content` is empty. Spec-compliant servers mirror
  it as a serialized text block for older clients, and re-parsing that text
  is lossy (numeric-looking strings become numbers, unparsable JSON stays a
  string). This matches the Python SDK. A FastMCP-style single-key
  `{ result: value }` wrapper is unwrapped; an object that merely has a
  `result` key among others passes through untouched.
- Drop the `structured_output` branch: it is not an MCP field and never
  matched.
- When a stdio server fails to connect while its stderr is suppressed, log
  a hint pointing at UTCP_MCP_CHILD_STDERR=inherit.
- Tests for result processing (structuredContent precedence, empty
  content, wrapper unwrapping, text fallback) and for schema
  dereferencing (a circular schema stays JSON-serializable with its $defs
  intact; acyclic refs are still inlined).
- README section on child process stderr.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…down races

Addresses cubic review on #42:

- FastMCP wraps only non-object returns as { result: value }, so a
  single-key { result: { ... } } is a genuine object return. Unwrap only
  when the inner value is not a plain object. Tests added for the array
  wrapper and the genuine single-key object return.
- The "stderr was suppressed" hint is gated on an unchanged close
  generation, so a close() racing with connect() no longer reports a
  startup failure that did not happen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…obustness

mcp: circular-safe schema deref, quiet child stderr, prefer structuredContent
Addresses cubic review on #41:

- OAuth2 tokens are cached per full configuration (token URL, client id,
  secret, scope) instead of per client_id, and the token URL is validated
  before the cache is consulted. Token requests are bounded by the call
  timeout (Streamable HTTP) or the handshake timeout (SSE) so a stalled
  token endpoint cannot hang a call.
- Streamable HTTP rejects body_field with http_method GET up front with a
  clear error; fetch() would otherwise throw a TypeError.
- Path parameters: every occurrence is substituted, and both `{param}`
  and the README's `${param}` form are accepted.
- SSE: the handshake is bounded by HANDSHAKE_TIMEOUT_MS (30 s) via an
  AbortController that is released once headers arrive, so body reads
  stay unbounded. A reconnect handshake that fails counts as an 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: asks for.
- SSE framing: a CR ending a chunk is held until the next chunk, so a
  CRLF split across reads no longer dispatches an event early. An event
  exceeding MAX_EVENT_BUFFER_CHARS (16 Mi) without a delimiter raises
  SseProtocolError, which is never retried.

Tests for each of the above.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… GET body guard

Addresses the second cubic round on #41:

- The tool call's AbortController is now created before the token fetch
  and its signal is passed down through _finalizeAuthHeaders into
  _handleOAuth2, so both credential methods, the request and the stream
  share one deadline. Previously each credential method restarted a full
  timeout, so a stalled token endpoint could take twice the configured
  time before the call's own timer even started. SSE uses a
  handshake-sized deadline for the token phase; discovery calls get the
  same treatment in both protocols.
- Streamable HTTP rejects a GET whenever body_field was supplied by the
  caller, including with an undefined value, rather than only when a
  body ended up being sent.

Tests: the stalled-token test now proves a single budget (800 ms timeout
must finish well under 1600 ms), and a GET with an undefined body field
is rejected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…try/finally

Since the deadline timer is now created before the token fetch, a throw
while building the query string or serializing the body (a circular
object, say) exited the generator without clearing it. All post-timer
work now sits in the one try whose finally clears the timer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ming-single-chunk-and-sse-reconnect

Implement SSE and Streamable HTTP protocols, fix CLI streaming
On a non-2xx response, axios throws an AxiosError whose `.message` is the
generic "Request failed with status code 403" — the response body, where
servers put the actual reason (e.g. { "error": "..." }), lives on
`error.response.data` and is lost to every caller that only reads `.message`.
It is also dropped entirely when the error crosses a serialization boundary
(e.g. JSON.stringify inside an isolated-vm tool runner like @utcp/code-mode),
because `Error.message` is non-enumerable and AxiosError doesn't serialize its
`response`. The caller is left with just a status code and no explanation.

callTool now normalizes a failed HTTP call into an Error that folds the status
and server body into the message AND attaches enumerable `status` / `data`
fields, so the reason survives both `.message` readers and structured
serialization. Non-HTTP errors (network, timeout) pass through unchanged.

Adds a /forbidden test route (403 + JSON body) and a callTool test asserting
the thrown error carries the body in its message and round-trips through
JSON.stringify with status + data intact.
…iscovery

The same body-swallowing pattern existed in the fetch-based streamable_http and
sse protocols: on a non-2xx during manual discovery (registerManual) they threw
`HTTP ${status}: ${statusText}` without ever reading the response body, so a
server that refuses discovery with a descriptive 403/400 surfaced only as
"HTTP 403: Forbidden" in the returned errors[].

Both now read the body before throwing and fold it into the message (falling
back to statusText when the body is empty). Their callTool paths are stubs
(no HTTP call yet), so discovery is the only real failure surface today.

Adds a GET /forbidden-discovery route (403 + text body) and tests asserting
both protocols' registerManual surfaces the body, not just the status code.
`_normalizeToolError` preferred `data.error` / `data.message` / `data.detail`
unconditionally, but some APIs nest an OBJECT there (e.g.
{ error: { code, reason } }). Using it directly interpolated as
"[object Object]", hiding the real detail. Now only a STRING candidate is
used; a non-string falls through to JSON.stringify(data) so the structure
shows. The raw object is still preserved on the error's `data` field.

Adds a /forbidden-object route (422 + nested object body) and a callTool
test asserting the message contains the structured detail (not
"[object Object]") and `data` holds the original object.

Issue identified by cubic.

@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.

2 issues found across 11 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="packages/http/src/sse_communication_protocol.ts">

<violation number="1" location="packages/http/src/sse_communication_protocol.ts:589">
P2: When a server sends a malformed `retry:` value such as `20ms` or `-1`, `parseInt` still changes the reconnect delay. Accept only digit-only, finite values so malformed server fields are ignored.</violation>
</file>

<file name="packages/mcp/tests/mcp_communication_protocol.test.ts">

<violation number="1" location="packages/mcp/tests/mcp_communication_protocol.test.ts:783">
P2: The assertion `expect(out.properties.root).toEqual({ $ref: "#/$defs/node" })` contradicts the actual `circular: 'ignore'` behavior of @apidevtools/json-schema-ref-parser (v15). Only the ref on the cycle itself (`next` inside node) is left as a $ref; the acyclic first ref `properties.root` → `#/$defs/node` is inlined into the node object. This also contradicts the sibling "acyclic refs are still inlined" test, which asserts the structurally identical first ref `properties.a` → `$defs.s` IS inlined. As written, `out.properties.root` deep-equals the node object, not the $ref, so the test fails. Expect the inlined node instead: root should be the object with `value` and a `next` $ref.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/http/src/sse_communication_protocol.ts Outdated
Comment on lines +589 to +591
const retry = parseInt(value, 10);
if (!Number.isNaN(retry)) {
event.retry = retry;

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 sends a malformed retry: value such as 20ms or -1, parseInt still changes the reconnect delay. Accept only digit-only, finite values so malformed server fields are ignored.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/http/src/sse_communication_protocol.ts, line 589:

<comment>When a server sends a malformed `retry:` value such as `20ms` or `-1`, `parseInt` still changes the reconnect delay. Accept only digit-only, finite values so malformed server fields are ignored.</comment>

<file context>
@@ -201,22 +273,439 @@ export class SseCommunicationProtocol implements CommunicationProtocol {
+      } else if (field === 'id') {
+        event.id = value;
+      } else if (field === 'retry') {
+        const retry = parseInt(value, 10);
+        if (!Number.isNaN(retry)) {
+          event.retry = retry;
</file context>
Suggested change
const retry = parseInt(value, 10);
if (!Number.isNaN(retry)) {
event.retry = retry;
if (/^\d+$/.test(value)) {
const retry = Number(value);
if (Number.isFinite(retry)) {
event.retry = retry;
}
}

Comment thread packages/http/src/sse_communication_protocol.ts Outdated
Comment thread packages/http/src/streamable_http_communication_protocol.ts Outdated
Comment thread packages/http/src/streamable_http_communication_protocol.ts Outdated
// stringify throws; with `circular: 'ignore'` every $ref on the cycle
// stays a $ref string, which serializes fine.
expect(() => JSON.stringify(out)).not.toThrow();
expect(out.properties.root).toEqual({ $ref: "#/$defs/node" });

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: The assertion expect(out.properties.root).toEqual({ $ref: "#/$defs/node" }) contradicts the actual circular: 'ignore' behavior of @apidevtools/json-schema-ref-parser (v15). Only the ref on the cycle itself (next inside node) is left as a $ref; the acyclic first ref properties.root#/$defs/node is inlined into the node object. This also contradicts the sibling "acyclic refs are still inlined" test, which asserts the structurally identical first ref properties.a$defs.s IS inlined. As written, out.properties.root deep-equals the node object, not the $ref, so the test fails. Expect the inlined node instead: root should be the object with value and a next $ref.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/mcp/tests/mcp_communication_protocol.test.ts, line 783:

<comment>The assertion `expect(out.properties.root).toEqual({ $ref: "#/$defs/node" })` contradicts the actual `circular: 'ignore'` behavior of @apidevtools/json-schema-ref-parser (v15). Only the ref on the cycle itself (`next` inside node) is left as a $ref; the acyclic first ref `properties.root` → `#/$defs/node` is inlined into the node object. This also contradicts the sibling "acyclic refs are still inlined" test, which asserts the structurally identical first ref `properties.a` → `$defs.s` IS inlined. As written, `out.properties.root` deep-equals the node object, not the $ref, so the test fails. Expect the inlined node instead: root should be the object with `value` and a `next` $ref.</comment>

<file context>
@@ -713,4 +713,88 @@ describe("McpCommunicationProtocol", () => {
+      // stringify throws; with `circular: 'ignore'` every $ref on the cycle
+      // stays a $ref string, which serializes fine.
+      expect(() => JSON.stringify(out)).not.toThrow();
+      expect(out.properties.root).toEqual({ $ref: "#/$defs/node" });
+      // The definitions the leftover $refs point at must survive so the
+      // schema remains resolvable by whoever consumes it.
</file context>

Comment thread packages/mcp/src/mcp_communication_protocol.ts
Comment thread packages/http/src/sse_communication_protocol.ts
Comment thread packages/mcp/src/mcp_communication_protocol.ts Outdated
Comment thread packages/http/tests/streamable_http_communication_protocol.test.ts Outdated
h3xxit and others added 6 commits September 4, 2026 16:03
…ail length

Addresses cubic review on #44:

- _normalizeToolError used an empty body string as the detail, producing
  a message ending in a bare colon that was less informative than the
  raw axios message. A blank body now falls back to the status text,
  then axios's message. The detail is capped at 2000 characters (the raw
  body stays intact on `data`).
- SSE and Streamable HTTP discovery truncate the error body to 200
  characters, matching their streaming paths, so a huge error page does
  not land in errors[] and logs in full.

Tests for the blank body, the truncated call message, and the truncated
discovery error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mirrors the fix on python-utcp#102: the first of error / message /
detail that is present decides. A non-blank string is the reason; an
object falls through to the full JSON so its structure stays visible
instead of being skipped in favour of a later generic message field.
Null and blank strings are still skipped. Test added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… pair

Addresses cubic review on #44. Slicing the detail by UTF-16 unit could
split a surrogate pair at the boundary and leave a lone surrogate in the
message. All three truncation sites (tool-call error detail, SSE and
Streamable HTTP discovery) now truncate by code point. Test with an emoji
sitting exactly on the boundary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Addresses cubic review on #44. Array.from(rawDetail) materialised every
code point of a possibly multi-megabyte body before the cap applied.
A small truncateByCodePoint helper walks the string with an iterator
and stops at the cap, and short strings skip the walk entirely. Used at
all three truncation sites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…surface-error-body-rebased

http: surface the server's error body on failed calls and discovery (lands #26)
… by full config

Pre-release review of dev. Two fixes cubic found in the streaming
protocols also applied to the plain HTTP protocol, where they were
pre-existing:

- Path parameters: a single buildUrlWithPathParams helper (new _url.ts)
  now backs all three protocols. Every occurrence of a parameter is
  substituted (the HTTP protocol replaced only the first, then threw
  "Missing required path parameter" for the repeat), and the `${param}`
  form the README documents works everywhere.
- The HTTP protocol's OAuth2 token cache is keyed by token URL, client
  id, secret and scope instead of client_id alone, matching the SSE and
  Streamable HTTP protocols.
- Removed a stale duplicate comment in _normalizeToolError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
h3xxit and others added 2 commits September 4, 2026 17:17
- Logs no longer include the query string of a streaming call, which
  could carry an API key with location 'query' or a sensitive argument.
- sse: an event block without an `event:` field has the type "message"
  (spec), so event_type: 'message' 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.
- streamable_http: an NDJSON line that never ends is rejected at
  MAX_LINE_CHARS (16 Mi) instead of buffering until the call deadline,
  mirroring the SSE event cap.
- Error bodies on the streaming and discovery paths are read bounded
  (64 KiB), with control characters collapsed, then truncated by code
  point; the HTTP protocol's discovery errors now carry the body too.
- _normalizeToolError keeps the original axios error reachable as
  non-enumerable `cause`, `response` and `code`, so callers inspecting
  err.response.headers keep working while JSON serialization stays clean.
- The HTTP protocol validates the OAuth2 token URL before consulting the
  token cache, like the streaming protocols.
- close() docs say what they do (clear cached tokens); README documents
  the total-deadline timeout, GET-only reconnection and the "message"
  default event type.

Tests for each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- http: the auth code the SSE and Streamable HTTP protocols had copied
  from each other (API key / Basic / OAuth2-user application, header
  finalisation, the OAuth2 client-credentials flow) now lives once in
  _auth.ts, so a security fix lands in one place.
- 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, so an
  uppercase application/x-ndjson is parsed instead of yielded as bytes.
- mcp: the stderr hint is worded conditionally, since a handshake
  rejection after a successful spawn is not explained by stderr. The
  legacy non-standard `structured_output` field is accepted again as a
  fallback so no server silently regresses; README wording updated.
- tests: the stalled-token test asserts the second credential method
  never started (one request seen) instead of a wall-clock bound.

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 11 findings, all handled in #45 (targets dev, so this PR picks them up once it merges):

  • Empty id: / Last-Event-ID, event_type: 'message' default, streaming error body read in full, unbounded NDJSON line: fixed.
  • retry: digits only: fixed, plus a test.
  • Uppercase Content-Type in Streamable HTTP: fixed.
  • Duplicated OAuth2 / auth-header code: extracted into _auth.ts, shared by SSE and Streamable HTTP; path-parameter substitution likewise shared with the HTTP protocol via _url.ts.
  • Legacy structured_output: accepted again as a fallback after structuredContent, README wording updated.
  • stderr hint wording: adopted.
  • Wall-clock assertion in the stalled-token test: replaced by asserting the token endpoint saw exactly one request.
  • Circular-schema test assertion: not changed. The test passes as written on this branch (bun test packages/mcp, 42 pass); with circular: 'ignore' json-schema-ref-parser 15 leaves every $ref on the cycle unresolved, including the first hop into it, which is what the assertion checks.

#45 also carries fixes from my own review: query-string secrets no longer logged, POST streams never re-issued on reconnect, bounded error-body reads everywhere, the original axios error kept reachable on the normalized error.

h3xxit and others added 7 commits September 4, 2026 17:40
Addresses cubic on #45:

- _url: path parameters are matched against own properties only, so a
  template like /{constructor} raises "Missing required path parameter"
  instead of substituting an inherited function.
- streamable_http: a complete NDJSON line longer than MAX_LINE_CHARS
  that arrives in one read hits the cap too, not only unterminated ones.
- _text: readErrorDetail slices an oversized chunk to the remaining
  budget before decoding.
- http: an error body made only of control characters falls back to the
  status text instead of a bare colon; discovery errors are capped at
  200 characters like the other protocols' discovery paths.
- sse: the response media type is compared exactly (parameters such as
  charset allowed), so text/event-stream-invalid no longer passes.
- tests: the query-secret leak test also spies console.error and
  exercises a failing call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- _auth: a non-2xx token response has its body cancelled before the
  Basic-Auth retry, so repeated auth failures do not accumulate open
  connections.
- sse: at end of stream a held-back CR is treated as the line terminator
  it is, so a final blank line ending in a lone CR still completes the
  last event; only genuinely incomplete events are discarded. The
  "reconnect disabled for POST" note is logged only when a drop actually
  happens, not on every successful POST call. Absurdly long retry digit
  strings are ignored.
- _text: control-character collapsing covers the C1 range too.
- tests: the leak test asserts the error sink fired; new CR-at-EOF test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing ignored

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…olish

http: share path-param substitution across protocols; key OAuth cache by full config
ensureSecureUrl 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 via _rejectRemoteLoopbackToolUrls. Manuals discovered from loopback (local dev) stay exempt. Adds unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MCP HTTP transport fetches an OAuth2 client-credentials token from the call template's token_url but did not validate that URL, unlike the HTTP plugin. Validate it with ensureSecureMcpUrl before posting client credentials, so a manual cannot direct them at an arbitrary host. 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.

5 issues found across 4 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="packages/mcp/tests/oauth_token_url_security.test.ts">

<violation number="1" location="packages/mcp/tests/oauth_token_url_security.test.ts:19">
P2: This assertion does not verify that credentials are never sent. Stub `_axiosInstance.post` and assert its call count remains zero, so a post-before-validation regression fails.</violation>

<violation number="2" location="packages/mcp/tests/oauth_token_url_security.test.ts:26">
P2: This test depends on the host’s port 1 state, making the suite environment-dependent and potentially slow or credential-posting. Use a per-test protocol with a mocked rejected `post` to exercise the loopback guard without real network I/O.</violation>
</file>

<file name="packages/http/src/http_communication_protocol.ts">

<violation number="1" location="packages/http/src/http_communication_protocol.ts:419">
P1: Remote manuals can still make OAuth2 tool calls send client credentials to a loopback `auth.token_url`. Extend the remote-origin restriction to OAuth2 token URLs, or preserve the remote trust context through token fetching.</violation>

<violation number="2" location="packages/http/src/http_communication_protocol.ts:421">
P1: A remote manual can bypass this guard with a dynamic host such as `http://{host}:8080/secret`. The guard sees the unresolved template before substitution, then the final loopback URL passes `ensureSecureUrl`, enabling SSRF. Reject placeholders in URL authorities or carry the remote-manual trust decision into the post-substitution invocation check.</violation>
</file>

<file name="packages/mcp/src/mcp_communication_protocol.ts">

<violation number="1" location="packages/mcp/src/mcp_communication_protocol.ts:771">
P1: When an allowed token endpoint redirects with 307/308, Axios can replay the OAuth request and its client secret to the redirect target because this validation covers only the initial URL. Disable redirects for token requests or validate every redirect before replaying credentials.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

*/
private _rejectRemoteLoopbackToolUrls(discoveryUrl: string, manual: { tools?: Array<any> }): void {
if (isLoopbackUrl(discoveryUrl)) return;
for (const tool of manual.tools || []) {

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: Remote manuals can still make OAuth2 tool calls send client credentials to a loopback auth.token_url. Extend the remote-origin restriction to OAuth2 token URLs, or preserve the remote trust context through token fetching.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/http/src/http_communication_protocol.ts, line 419:

<comment>Remote manuals can still make OAuth2 tool calls send client credentials to a loopback `auth.token_url`. Extend the remote-origin restriction to OAuth2 token URLs, or preserve the remote trust context through token fetching.</comment>

<file context>
@@ -402,6 +403,31 @@ export class HttpCommunicationProtocol implements CommunicationProtocol {
+   */
+  private _rejectRemoteLoopbackToolUrls(discoveryUrl: string, manual: { tools?: Array<any> }): void {
+    if (isLoopbackUrl(discoveryUrl)) return;
+    for (const tool of manual.tools || []) {
+      const toolUrl = tool?.tool_call_template?.url;
+      if (typeof toolUrl === 'string' && isLoopbackUrl(toolUrl)) {
</file context>

if (isLoopbackUrl(discoveryUrl)) return;
for (const tool of manual.tools || []) {
const toolUrl = tool?.tool_call_template?.url;
if (typeof toolUrl === 'string' && isLoopbackUrl(toolUrl)) {

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 guard with a dynamic host such as http://{host}:8080/secret. The guard sees the unresolved template before substitution, then the final loopback URL passes ensureSecureUrl, enabling SSRF. Reject placeholders in URL authorities or carry the remote-manual trust decision into the post-substitution invocation check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/http/src/http_communication_protocol.ts, line 421:

<comment>A remote manual can bypass this guard with a dynamic host such as `http://{host}:8080/secret`. The guard sees the unresolved template before substitution, then the final loopback URL passes `ensureSecureUrl`, enabling SSRF. Reject placeholders in URL authorities or carry the remote-manual trust decision into the post-substitution invocation check.</comment>

<file context>
@@ -402,6 +403,31 @@ export class HttpCommunicationProtocol implements CommunicationProtocol {
+    if (isLoopbackUrl(discoveryUrl)) return;
+    for (const tool of manual.tools || []) {
+      const toolUrl = tool?.tool_call_template?.url;
+      if (typeof toolUrl === 'string' && isLoopbackUrl(toolUrl)) {
+        throw new Error(
+          `Security error during manual discovery: a manual fetched from ${JSON.stringify(discoveryUrl)} ` +
</file context>

private async _handleOAuth2(authDetails: OAuth2Auth): Promise<string> {
// Validate the token endpoint before sending credentials to it, so a
// manual cannot direct the operator's client secret at an arbitrary host.
ensureSecureMcpUrl(authDetails.token_url, 'MCP OAuth2 token 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: When an allowed token endpoint redirects with 307/308, Axios can replay the OAuth request and its client secret to the redirect target because this validation covers only the initial URL. Disable redirects for token requests or validate every redirect before replaying credentials.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/mcp/src/mcp_communication_protocol.ts, line 771:

<comment>When an allowed token endpoint redirects with 307/308, Axios can replay the OAuth request and its client secret to the redirect target because this validation covers only the initial URL. Disable redirects for token requests or validate every redirect before replaying credentials.</comment>

<file context>
@@ -766,6 +766,9 @@ export class McpCommunicationProtocol implements CommunicationProtocol {
   private async _handleOAuth2(authDetails: OAuth2Auth): Promise<string> {
+    // Validate the token endpoint before sending credentials to it, so a
+    // manual cannot direct the operator's client secret at an arbitrary host.
+    ensureSecureMcpUrl(authDetails.token_url, 'MCP OAuth2 token URL');
     const clientId = authDetails.client_id;
     const cachedToken = this._oauthTokens.get(clientId);
</file context>


test("a loopback token URL passes the guard (then fails on connection, not the guard)", async () => {
// Port 1 refuses immediately; the resulting error must not be the guard's.
await expect(protocol._handleOAuth2(auth("http://127.0.0.1:1/token"))).rejects.not.toThrow(

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 depends on the host’s port 1 state, making the suite environment-dependent and potentially slow or credential-posting. Use a per-test protocol with a mocked rejected post to exercise the loopback guard without real network I/O.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/mcp/tests/oauth_token_url_security.test.ts, line 26:

<comment>This test depends on the host’s port 1 state, making the suite environment-dependent and potentially slow or credential-posting. Use a per-test protocol with a mocked rejected `post` to exercise the loopback guard without real network I/O.</comment>

<file context>
@@ -0,0 +1,30 @@
+
+  test("a loopback token URL passes the guard (then fails on connection, not the guard)", async () => {
+    // Port 1 refuses immediately; the resulting error must not be the guard's.
+    await expect(protocol._handleOAuth2(auth("http://127.0.0.1:1/token"))).rejects.not.toThrow(
+      "Security error",
+    );
</file context>

});

test("rejects a non-loopback plain-HTTP token URL before sending credentials", async () => {
await expect(protocol._handleOAuth2(auth("http://attacker.example/token"))).rejects.toThrow(

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 assertion does not verify that credentials are never sent. Stub _axiosInstance.post and assert its call count remains zero, so a post-before-validation regression fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/mcp/tests/oauth_token_url_security.test.ts, line 19:

<comment>This assertion does not verify that credentials are never sent. Stub `_axiosInstance.post` and assert its call count remains zero, so a post-before-validation regression fails.</comment>

<file context>
@@ -0,0 +1,30 @@
+  });
+
+  test("rejects a non-loopback plain-HTTP token URL before sending credentials", async () => {
+    await expect(protocol._handleOAuth2(auth("http://attacker.example/token"))).rejects.toThrow(
+      "Security error",
+    );
</file context>

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.

3 participants