Conversation
- 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.
There was a problem hiding this comment.
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
| const retry = parseInt(value, 10); | ||
| if (!Number.isNaN(retry)) { | ||
| event.retry = retry; |
There was a problem hiding this comment.
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>
| 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; | |
| } | |
| } |
| // 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" }); |
There was a problem hiding this comment.
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>
…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>
- 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>
|
Disposition of the 11 findings, all handled in #45 (targets dev, so this PR picks them up once it merges):
#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. |
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>
There was a problem hiding this comment.
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 || []) { |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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>
Summary by cubic
Replaces the SSE and Streamable HTTP placeholder stubs with working transports, and changes CLI
callToolStreamingfrom 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
Last-Event-ID; body-bearing calls are not retried.body_fieldare rejected.MCP
structuredContent, retain the legacystructured_outputfallback, and unwrap non-object FastMCP{result}wrappers.UTCP_MCP_CHILD_STDERR=inheritto show it.Written for commit 002ea10. Summary will update on new commits.