From eee5dda7a324682f2478677de8f0ea37bcdc5a82 Mon Sep 17 00:00:00 2001 From: ashish993 Date: Sun, 24 May 2026 14:44:28 +0800 Subject: [PATCH 1/4] security: fix high-severity vulnerabilities - Pin all GitHub Actions to immutable commit SHAs to prevent supply chain attacks via mutable tag references (actions/checkout, pnpm/action-setup, actions/setup-node, changesets/action) - Validate login_url is HTTPS before passing to system browser opener to prevent file:// or other protocol exploitation from a compromised broker response - Sanitize login_url in preAuthHelpMessage to HTTPS-only before embedding in LLM agent skill prompts to prevent prompt injection from a compromised broker --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 8 ++++---- packages/cli/lib/cli.js | 21 ++++++++++++++++++++- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cefad4..c344e23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,15 +12,15 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Set up pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 with: version: 10.18.3 - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6c0318..930f05d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,18 +19,18 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 token: ${{ env.RELEASE_GITHUB_TOKEN }} - name: Set up pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 with: version: 10.18.3 - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 24 cache: pnpm @@ -97,7 +97,7 @@ jobs: - name: Create release PR or publish id: changesets - uses: changesets/action@v1 + uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1 with: version: pnpm run version-packages publish: pnpm run publish-packages diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 4d828b6..3549e8a 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -18,13 +18,23 @@ class InvalidArgumentsError extends Error { } export function preAuthHelpMessage(loginUrl) { + let safeUrl; + try { + const parsed = new URL(loginUrl); + if (parsed.protocol !== "https:") { + throw new Error("non-https"); + } + safeUrl = parsed.href; + } catch { + safeUrl = "[authorization URL unavailable]"; + } return `Hi, I'm CALL-E 👋 I can help you make phone calls, ask for information, and handle phone-related tasks. I'll also keep you updated on the call status, what was discussed, and the key points. Before we officially begin, I'll send you the call goal for confirmation. Before we start, please complete authorization here: -${loginUrl}`; +${safeUrl}`; } export const POST_AUTH_HELP_MESSAGE = `Great, authorization is complete ✨ @@ -878,6 +888,15 @@ export async function runCli(argv, deps = {}) { const stdout = deps.stdout || ((text) => process.stdout.write(text)); const stderr = deps.stderr || ((text) => process.stderr.write(`${text}\n`)); const openBrowser = deps.openBrowser || (async (url) => { + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + throw new Error(`Refusing to open browser: invalid login URL`); + } + if (parsedUrl.protocol !== "https:") { + throw new Error(`Refusing to open browser: login URL must use HTTPS (got '${parsedUrl.protocol}')`); + } const { spawn } = await import("node:child_process"); const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; From a778a8d47919334503292624db00d6194affd4d8 Mon Sep 17 00:00:00 2001 From: ashish993 Date: Sun, 24 May 2026 14:49:05 +0800 Subject: [PATCH 2/4] security: fix medium/low severity issues - Replace MD5 with SHA-256 in serverHash() cache key (packages/core/lib/cache.js) - Validate CALLE_TELEMETRY_URL is HTTPS before use (packages/cli/lib/config.js) - Add max polling attempt cap (600) to loginWithBroker alongside time deadline (packages/core/lib/broker-client.js) - Add retry with exponential backoff for 429/502/503/504 in requestJsonRpc (packages/core/lib/mcp-client.js) - Add MCP protocol version mismatch warning to stderr on initialize (packages/core/lib/mcp-client.js + cli.js) --- packages/cli/lib/cli.js | 3 + packages/cli/lib/config.js | 11 ++- packages/core/lib/broker-client.js | 5 +- packages/core/lib/cache.js | 2 +- packages/core/lib/mcp-client.js | 119 +++++++++++++++++++---------- 5 files changed, 95 insertions(+), 45 deletions(-) diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 3549e8a..324dc32 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -913,6 +913,9 @@ export async function runCli(argv, deps = {}) { const { options, positional } = parseOptions(rest); const config = resolveRuntimeConfig(options, deps.env || process.env); + config._onProtocolVersionMismatch = (serverVersion, clientVersion) => { + process.stderr.write(`[calle] Warning: MCP protocol version mismatch — server reports ${serverVersion}, client expects ${clientVersion}.\n`); + }; const captureTelemetry = createCommandTelemetry({ config, group, command, deps }); if (prePlanInvokedCommand(group, command)) { await captureTelemetry("cli_invoked"); diff --git a/packages/cli/lib/config.js b/packages/cli/lib/config.js index 66f1029..f3df6fd 100644 --- a/packages/cli/lib/config.js +++ b/packages/cli/lib/config.js @@ -78,7 +78,16 @@ function resolveTelemetryEnabled(options = {}, env = {}) { function resolveTelemetryUrl({ telemetryUrl, baseUrl }, env = {}) { const configured = firstOptionValue(telemetryUrl) || env.CALLE_TELEMETRY_URL; if (configured) { - return String(configured); + const url = String(configured); + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new Error(`Telemetry URL must use HTTPS, got: ${parsed.protocol}`); + } + } catch (err) { + throw new Error(`Invalid CALLE_TELEMETRY_URL: ${err.message}`); + } + return url; } return `${normalizeBaseUrl(baseUrl)}/api/ui-telemetry/track`; } diff --git a/packages/core/lib/broker-client.js b/packages/core/lib/broker-client.js index 412aa34..a834cb3 100644 --- a/packages/core/lib/broker-client.js +++ b/packages/core/lib/broker-client.js @@ -103,8 +103,11 @@ export async function loginWithBroker(config, { } const deadline = Date.now() + Number(config.pollTimeoutSeconds || 300) * 1000; + const maxAttempts = Number(config.pollMaxAttempts || 0) || 600; + let attempt = 0; let current = pending; - while (Date.now() < deadline) { + while (Date.now() < deadline && attempt < maxAttempts) { + attempt += 1; const statusPayload = await getBrokerSessionStatus(config, current, { fetchImpl }); const status = String(statusPayload.status || current.status || "PENDING").toUpperCase(); current = { diff --git a/packages/core/lib/cache.js b/packages/core/lib/cache.js index f484d62..c4ef0a2 100644 --- a/packages/core/lib/cache.js +++ b/packages/core/lib/cache.js @@ -3,7 +3,7 @@ import path from "node:path"; import crypto from "node:crypto"; export function serverHash(serverUrl) { - return crypto.createHash("md5").update(serverUrl, "utf8").digest("hex"); + return crypto.createHash("sha256").update(serverUrl, "utf8").digest("hex"); } export function tokenCachePath(cacheRoot, serverUrl) { diff --git a/packages/core/lib/mcp-client.js b/packages/core/lib/mcp-client.js index 8c45fc6..4b187fc 100644 --- a/packages/core/lib/mcp-client.js +++ b/packages/core/lib/mcp-client.js @@ -50,56 +50,86 @@ function parseResponseBody(text) { return JSON.parse(text); } -async function requestJsonRpc(fetchImpl, url, { headers, payload, timeoutMs }) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - if (typeof timeout.unref === "function") { - timeout.unref(); +const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); +const MAX_RETRY_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 500; + +function retryDelayMs(attempt, retryAfterHeader) { + const retryAfter = Number(retryAfterHeader); + if (retryAfterHeader && !Number.isNaN(retryAfter) && retryAfter > 0) { + return Math.min(retryAfter * 1000, 30000); } + return Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), 10000); +} - try { - const response = await fetchImpl(url, { - method: "POST", - headers, - body: JSON.stringify(payload), - signal: controller.signal, - }); - const text = await response.text(); - let body = null; - try { - body = parseResponseBody(text); - } catch { - body = null; - } - const responseHeaders = Object.fromEntries(response.headers.entries()); - - if (!response.ok) { - throw new McpHttpError(`MCP HTTP ${response.status} for ${payload.method}`, { - statusCode: response.status, - responseText: text, - payload: body, - headers: responseHeaders, - }); +async function requestJsonRpc(fetchImpl, url, { headers, payload, timeoutMs, sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)) }) { + let lastError; + for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + if (typeof timeout.unref === "function") { + timeout.unref(); } - if (body?.error) { - const error = body.error; - throw new McpHttpError(error.message || `Remote MCP error for ${payload.method}`, { - payload: error, - headers: responseHeaders, - code: "mcp_error", + try { + const response = await fetchImpl(url, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: controller.signal, }); - } + const text = await response.text(); + let body = null; + try { + body = parseResponseBody(text); + } catch { + body = null; + } + const responseHeaders = Object.fromEntries(response.headers.entries()); + + if (!response.ok) { + const err = new McpHttpError(`MCP HTTP ${response.status} for ${payload.method}`, { + statusCode: response.status, + responseText: text, + payload: body, + headers: responseHeaders, + }); + if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < MAX_RETRY_ATTEMPTS) { + lastError = err; + await sleepImpl(retryDelayMs(attempt, responseHeaders["retry-after"])); + continue; + } + throw err; + } + + if (body?.error) { + const error = body.error; + throw new McpHttpError(error.message || `Remote MCP error for ${payload.method}`, { + payload: error, + headers: responseHeaders, + code: "mcp_error", + }); + } - return { body, headers: responseHeaders }; - } catch (error) { - if (error?.name === "AbortError") { - throw new McpHttpError(`MCP request timed out for ${payload.method}`, { code: "http_error" }); + return { body, headers: responseHeaders }; + } catch (error) { + if (error?.name === "AbortError") { + throw new McpHttpError(`MCP request timed out for ${payload.method}`, { code: "http_error" }); + } + if (error instanceof McpHttpError) { + throw error; + } + lastError = error; + if (attempt < MAX_RETRY_ATTEMPTS) { + await sleepImpl(retryDelayMs(attempt, null)); + continue; + } + throw error; + } finally { + clearTimeout(timeout); } - throw error; - } finally { - clearTimeout(timeout); } + throw lastError; } function requireFetch(fetchImpl) { @@ -168,6 +198,11 @@ async function openMcpSession({ config, fetchImpl }) { const sessionId = initialize.headers["mcp-session-id"] || initialize.headers["Mcp-Session-Id"] || ""; const rpcHeaders = sessionId ? { ...commonHeaders, "mcp-session-id": sessionId } : commonHeaders; + const serverProtocolVersion = initialize.body?.result?.protocolVersion; + if (serverProtocolVersion && serverProtocolVersion !== MCP_PROTOCOL_VERSION) { + config._onProtocolVersionMismatch?.(serverProtocolVersion, MCP_PROTOCOL_VERSION); + } + await requestJsonRpc(fetchImpl, config.serverUrl, { headers: rpcHeaders, payload: buildJsonRpcPayload({ From cf040ba0faa55615d609326eb0ba74644de6f5eb Mon Sep 17 00:00:00 2001 From: ashish993 Date: Sun, 24 May 2026 14:50:52 +0800 Subject: [PATCH 3/4] chore: ignore local run.md notes file --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a753e77..0e8afc8 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ logs/ .vscode/ .cursorignore .cursorindexingignore + +# Local notes +run.md From 9330d5d59d8ae1f0d7e6ce2f5979f1bff859a58e Mon Sep 17 00:00:00 2001 From: ashish993 Date: Sun, 24 May 2026 15:16:35 +0800 Subject: [PATCH 4/4] security: fix C-2/H-1/H-2/L-1/L-4/M-5 from enterprise review - C-2: remove cache_path/pending_cache_path (home dir paths) from all public JSON payloads in cli.js; statusPayload now validates pending_login_url HTTPS before including it - H-1: add 64 KB max-size guard on --args-json in parseJsonObject() - H-2: add max 10 --to-phone numbers and max 2000 char --goal limit in buildPlanArguments() - L-1: truncate server-controlled error messages to 200 chars and strip newlines in McpHttpError to prevent injection into LLM context - L-4: omit --cache-root from loginCommand() output when it equals the default (~/.calle-mcp/cli) to avoid leaking home paths in hints - M-5: align CI workflow to Node 24 (matches release workflow) - Also: allow http:// on loopback (localhost/127.0.0.1) for local dev/test; HTTPS enforcement still applies for all external URLs --- .github/workflows/ci.yml | 2 +- packages/cli/lib/cli.js | 57 +++++++++++++++++++++--------- packages/cli/lib/config.js | 39 +++++++++++++++++++- packages/core/lib/broker-client.js | 16 +++++++-- packages/core/lib/mcp-client.js | 6 +++- 5 files changed, 97 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c344e23..7605a3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 22 + node-version: 24 cache: pnpm - name: Install dependencies diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 324dc32..07eaa02 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -1,5 +1,5 @@ import { pendingCachePath, readJson, removeFile, tokenCachePath, tokenIsUsable } from "./cache.js"; -import { DEFAULT_BASE_URL, DEFAULT_CHANNEL, DEFAULT_CLIENT_NAME, DEFAULT_SCOPE, resolveRuntimeConfig } from "./config.js"; +import { DEFAULT_BASE_URL, DEFAULT_CACHE_ROOT, DEFAULT_CHANNEL, DEFAULT_CLIENT_NAME, DEFAULT_SCOPE, expandHomePath, resolveRuntimeConfig } from "./config.js"; import { ensurePendingLogin, loginWithBroker } from "./broker-client.js"; import { AuthRequiredError, @@ -343,18 +343,27 @@ function parsePositiveInteger(value, optionName) { return parsed; } +const ARGS_JSON_MAX_BYTES = 64 * 1024; // 64 KB + function parseJsonObject(value, optionName) { const raw = firstOptionValue(value); if (raw === undefined) { return {}; } + const rawStr = String(raw); + if (Buffer.byteLength(rawStr, "utf8") > ARGS_JSON_MAX_BYTES) { + throw new InvalidArgumentsError(`${optionName} exceeds maximum size of 64 KB`); + } try { - const parsed = JSON.parse(String(raw)); + const parsed = JSON.parse(rawStr); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("not object"); } return parsed; - } catch { + } catch (err) { + if (err instanceof InvalidArgumentsError) { + throw err; + } throw new InvalidArgumentsError(`${optionName} must be a JSON object`); } } @@ -391,8 +400,6 @@ function publicPendingLoginPayload({ config, cachePath, pendingPath, pending, cr status: "login_required", broker_base_url: config.brokerBaseUrl, server_url: config.serverUrl, - cache_path: cachePath, - pending_cache_path: pendingPath, pending_status: pending.status, pending_created: created, login_url: pending.login_url, @@ -406,8 +413,6 @@ function publicLoginPayload({ config, cachePath, pendingPath, tokenDocument, sta status, broker_base_url: config.brokerBaseUrl, server_url: config.serverUrl, - cache_path: cachePath, - pending_cache_path: pendingPath, expires_at: tokenDocument?.expires_at ?? null, ...(assistantHint ? { assistant_hint: assistantHint } : {}), }; @@ -418,16 +423,24 @@ function statusPayload(config) { const pendingPath = pendingCachePath(config.cacheRoot, config.serverUrl); const cacheDocument = readJson(cachePath); const pendingDocument = readJson(pendingPath); + const rawPendingLoginUrl = typeof pendingDocument?.login_url === "string" ? pendingDocument.login_url : null; + let pendingLoginUrl = null; + if (rawPendingLoginUrl) { + try { + const parsed = new URL(rawPendingLoginUrl); + pendingLoginUrl = parsed.protocol === "https:" ? parsed.href : null; + } catch { + pendingLoginUrl = null; + } + } return { server_url: config.serverUrl, - cache_path: cachePath, - pending_cache_path: pendingPath, cache_exists: cacheDocument !== null, pending_exists: pendingDocument !== null, usable: tokenIsUsable(cacheDocument, config.minTtlSeconds), expires_at: cacheDocument?.expires_at ?? null, pending_status: pendingDocument?.status ?? null, - pending_login_url: pendingDocument?.login_url ?? null, + ...(pendingLoginUrl ? { pending_login_url: pendingLoginUrl } : {}), }; } @@ -455,7 +468,7 @@ function shellQuote(value) { } function loginCommand(config) { - return [ + const parts = [ "calle", "auth", "login", @@ -467,11 +480,13 @@ function loginCommand(config) { config.authBaseUrl, "--channel", config.channel, - "--cache-root", - config.cacheRoot, - ] - .map(shellQuote) - .join(" "); + ]; + // Only include --cache-root when it differs from the default to avoid leaking home directory paths + const defaultCacheRoot = expandHomePath(DEFAULT_CACHE_ROOT); + if (config.cacheRoot !== defaultCacheRoot) { + parts.push("--cache-root", config.cacheRoot); + } + return parts.map(shellQuote).join(" "); } function callStatusCommand(config, runId, timezone = null) { @@ -633,10 +648,18 @@ function buildPlanArguments(options) { if (toPhones.length === 0) { throw new InvalidArgumentsError("Missing required --to-phone"); } + if (toPhones.length > 10) { + throw new InvalidArgumentsError("--to-phone: maximum 10 numbers per request"); + } + + const goal = requireStringOption(options, "goal", "--goal"); + if (goal.length > 2000) { + throw new InvalidArgumentsError("--goal: maximum 2000 characters"); + } const args = { to_phones: toPhones, - goal: requireStringOption(options, "goal", "--goal"), + goal, }; const language = optionalStringOption(options, "language"); const region = optionalStringOption(options, "region"); diff --git a/packages/cli/lib/config.js b/packages/cli/lib/config.js index f3df6fd..469ef92 100644 --- a/packages/cli/lib/config.js +++ b/packages/cli/lib/config.js @@ -131,7 +131,37 @@ export function formatIntegrationHeader(integrationContext) { return `${source}/${integration}/${version}`; } +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +function requireHttpsUrl(value, name) { + if (!value) { + return value; + } + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${name} must be a valid URL`); + } + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOOPBACK_HOSTNAMES.has(parsed.hostname))) { + throw new Error(`${name} must use HTTPS (got '${parsed.protocol}')`); + } + return value; +} + export function resolveRuntimeConfig(options = {}, env = process.env) { + if (options.baseUrl) { + requireHttpsUrl(options.baseUrl, "--base-url"); + } + if (options.serverUrl) { + requireHttpsUrl(options.serverUrl, "--server-url"); + } + if (options.brokerBaseUrl) { + requireHttpsUrl(options.brokerBaseUrl, "--broker-base-url"); + } + if (options.authBaseUrl) { + requireHttpsUrl(options.authBaseUrl, "--auth-base-url"); + } const baseUrl = normalizeBaseUrl(options.baseUrl || DEFAULT_BASE_URL); const channel = options.channel || DEFAULT_CHANNEL; const serverUrl = resolveServerUrl({ serverUrl: options.serverUrl, baseUrl, channel }); @@ -153,7 +183,14 @@ export function resolveRuntimeConfig(options = {}, env = process.env) { minTtlSeconds: Number(options.minTtlSeconds || DEFAULT_MIN_TTL_SECONDS), serverName: options.serverName || DEFAULT_SERVER_NAME, telemetryEnabled: resolveTelemetryEnabled(options, env), - telemetryUrl: resolveTelemetryUrl({ telemetryUrl: options.telemetryUrl, baseUrl }, env), + telemetryUrl: (() => { + try { + return resolveTelemetryUrl({ telemetryUrl: options.telemetryUrl, baseUrl }, env); + } catch (err) { + process.stderr.write(`[calle] Warning: ${err.message} — telemetry disabled.\n`); + return null; + } + })(), telemetryTimeoutSeconds: Number( firstOptionValue(options.telemetryTimeoutSeconds) || env.CALLE_TELEMETRY_TIMEOUT_SECONDS || diff --git a/packages/core/lib/broker-client.js b/packages/core/lib/broker-client.js index a834cb3..bac4934 100644 --- a/packages/core/lib/broker-client.js +++ b/packages/core/lib/broker-client.js @@ -95,10 +95,20 @@ export async function loginWithBroker(config, { const { pending, created } = await ensurePendingLogin(config, { fetchImpl, forceLogin }); if (created) { + let safeLoginUrl; + try { + const parsed = new URL(pending.login_url); + if (parsed.protocol !== "https:") { + throw new Error("non-https"); + } + safeLoginUrl = parsed.href; + } catch { + safeLoginUrl = null; + } stderr("Open the brokered login URL in your browser to continue:"); - stderr(pending.login_url); - if (!noBrowserOpen) { - await openBrowser(pending.login_url); + stderr(safeLoginUrl ?? "[authorization URL unavailable]"); + if (!noBrowserOpen && safeLoginUrl) { + await openBrowser(safeLoginUrl); } } diff --git a/packages/core/lib/mcp-client.js b/packages/core/lib/mcp-client.js index 4b187fc..28fa113 100644 --- a/packages/core/lib/mcp-client.js +++ b/packages/core/lib/mcp-client.js @@ -104,7 +104,11 @@ async function requestJsonRpc(fetchImpl, url, { headers, payload, timeoutMs, sle if (body?.error) { const error = body.error; - throw new McpHttpError(error.message || `Remote MCP error for ${payload.method}`, { + const rawMessage = typeof error.message === "string" ? error.message : null; + const safeMessage = rawMessage + ? rawMessage.slice(0, 200).replace(/[\r\n]+/g, " ").trim() + : `Remote MCP error for ${payload.method}`; + throw new McpHttpError(safeMessage, { payload: error, headers: responseHeaders, code: "mcp_error",