From 68db1a340f4d944aa130c13b48d6d9711215daed Mon Sep 17 00:00:00 2001 From: dhananjai1729 Date: Thu, 19 Mar 2026 17:01:59 +0530 Subject: [PATCH 1/4] feat(mcp): add SSE transport support for remote MCP servers --- src/agents/mcp-sse.test.ts | 125 +++++++++++++++++++++++++ src/agents/mcp-sse.ts | 65 +++++++++++++ src/agents/mcp-stdio.ts | 2 +- src/agents/pi-bundle-mcp-tools.test.ts | 75 +++++++++++++++ src/agents/pi-bundle-mcp-tools.ts | 79 ++++++++++++---- src/config/types.mcp.ts | 8 ++ 6 files changed, 336 insertions(+), 18 deletions(-) create mode 100644 src/agents/mcp-sse.test.ts create mode 100644 src/agents/mcp-sse.ts diff --git a/src/agents/mcp-sse.test.ts b/src/agents/mcp-sse.test.ts new file mode 100644 index 00000000000..10c79876a69 --- /dev/null +++ b/src/agents/mcp-sse.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { describeSseMcpServerLaunchConfig, resolveSseMcpServerLaunchConfig } from "./mcp-sse.js"; + +describe("resolveSseMcpServerLaunchConfig", () => { + it("resolves a valid https URL", () => { + const result = resolveSseMcpServerLaunchConfig({ + url: "https://mcp.example.com/sse", + }); + expect(result).toEqual({ + ok: true, + config: { + url: "https://mcp.example.com/sse", + headers: undefined, + }, + }); + }); + + it("resolves a valid http URL", () => { + const result = resolveSseMcpServerLaunchConfig({ + url: "http://localhost:3000/sse", + }); + expect(result).toEqual({ + ok: true, + config: { + url: "http://localhost:3000/sse", + headers: undefined, + }, + }); + }); + + it("includes headers when provided", () => { + const result = resolveSseMcpServerLaunchConfig({ + url: "https://mcp.example.com/sse", + headers: { + Authorization: "Bearer token123", + "X-Custom": "value", + }, + }); + expect(result).toEqual({ + ok: true, + config: { + url: "https://mcp.example.com/sse", + headers: { + Authorization: "Bearer token123", + "X-Custom": "value", + }, + }, + }); + }); + + it("coerces numeric and boolean header values to strings", () => { + const result = resolveSseMcpServerLaunchConfig({ + url: "https://mcp.example.com/sse", + headers: { "X-Count": 42, "X-Debug": true }, + }); + expect(result).toEqual({ + ok: true, + config: { + url: "https://mcp.example.com/sse", + headers: { "X-Count": "42", "X-Debug": "true" }, + }, + }); + }); + + it("rejects non-object input", () => { + const result = resolveSseMcpServerLaunchConfig("not-an-object"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("must be an object"); + } + }); + + it("rejects missing url", () => { + const result = resolveSseMcpServerLaunchConfig({ command: "npx" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("url is missing"); + } + }); + + it("rejects empty url", () => { + const result = resolveSseMcpServerLaunchConfig({ url: " " }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("url is missing"); + } + }); + + it("rejects invalid URL format", () => { + const result = resolveSseMcpServerLaunchConfig({ url: "not-a-url" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("not a valid URL"); + } + }); + + it("rejects non-http protocols", () => { + const result = resolveSseMcpServerLaunchConfig({ url: "ftp://example.com/sse" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("only http and https"); + } + }); + + it("trims whitespace from url", () => { + const result = resolveSseMcpServerLaunchConfig({ + url: " https://mcp.example.com/sse ", + }); + expect(result).toEqual({ + ok: true, + config: { + url: "https://mcp.example.com/sse", + headers: undefined, + }, + }); + }); +}); + +describe("describeSseMcpServerLaunchConfig", () => { + it("returns the url", () => { + expect(describeSseMcpServerLaunchConfig({ url: "https://mcp.example.com/sse" })).toBe( + "https://mcp.example.com/sse", + ); + }); +}); diff --git a/src/agents/mcp-sse.ts b/src/agents/mcp-sse.ts new file mode 100644 index 00000000000..3a3bc8f0b44 --- /dev/null +++ b/src/agents/mcp-sse.ts @@ -0,0 +1,65 @@ +type SseMcpServerLaunchConfig = { + url: string; + headers?: Record; +}; + +type SseMcpServerLaunchResult = + | { ok: true; config: SseMcpServerLaunchConfig } + | { ok: false; reason: string }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function toStringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const entries = Object.entries(value) + .map(([key, entry]) => { + if (typeof entry === "string") { + return [key, entry] as const; + } + if (typeof entry === "number" || typeof entry === "boolean") { + return [key, String(entry)] as const; + } + return null; + }) + .filter((entry): entry is readonly [string, string] => entry !== null); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +export function resolveSseMcpServerLaunchConfig(raw: unknown): SseMcpServerLaunchResult { + if (!isRecord(raw)) { + return { ok: false, reason: "server config must be an object" }; + } + if (typeof raw.url !== "string" || raw.url.trim().length === 0) { + return { ok: false, reason: "its url is missing" }; + } + const url = raw.url.trim(); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: `its url is not a valid URL: ${url}` }; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { + ok: false, + reason: `only http and https URLs are supported, got ${parsed.protocol}`, + }; + } + return { + ok: true, + config: { + url, + headers: toStringRecord(raw.headers), + }, + }; +} + +export function describeSseMcpServerLaunchConfig(config: SseMcpServerLaunchConfig): string { + return config.url; +} + +export type { SseMcpServerLaunchConfig, SseMcpServerLaunchResult }; diff --git a/src/agents/mcp-stdio.ts b/src/agents/mcp-stdio.ts index 77ab6171ca7..55b2877bd66 100644 --- a/src/agents/mcp-stdio.ts +++ b/src/agents/mcp-stdio.ts @@ -47,7 +47,7 @@ export function resolveStdioMcpServerLaunchConfig(raw: unknown): StdioMcpServerL if (typeof raw.url === "string" && raw.url.trim().length > 0) { return { ok: false, - reason: "only stdio MCP servers are supported right now", + reason: "not a stdio server (has url)", }; } return { ok: false, reason: "its command is missing" }; diff --git a/src/agents/pi-bundle-mcp-tools.test.ts b/src/agents/pi-bundle-mcp-tools.test.ts index 69b2839eb94..8c58db3446d 100644 --- a/src/agents/pi-bundle-mcp-tools.test.ts +++ b/src/agents/pi-bundle-mcp-tools.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import http from "node:http"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; @@ -8,6 +9,7 @@ import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js"; const require = createRequire(import.meta.url); const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); +const SDK_SERVER_SSE_PATH = require.resolve("@modelcontextprotocol/sdk/server/sse.js"); const tempDirs: string[] = []; @@ -181,4 +183,77 @@ describe("createBundleMcpToolRuntime", () => { await runtime.dispose(); } }); + + it("loads configured SSE MCP tools via url", async () => { + // Dynamically import the SSE server transport from the SDK. + const { McpServer } = await import(SDK_SERVER_MCP_PATH); + const { SSEServerTransport } = await import(SDK_SERVER_SSE_PATH); + + const mcpServer = new McpServer({ name: "sse-probe", version: "1.0.0" }); + mcpServer.tool("sse_probe", "SSE MCP probe", async () => { + return { + content: [{ type: "text", text: "FROM-SSE" }], + }; + }); + + // Start an HTTP server that hosts the SSE MCP transport. + let sseTransport: + | { + handlePostMessage: (req: http.IncomingMessage, res: http.ServerResponse) => Promise; + } + | undefined; + const httpServer = http.createServer(async (req, res) => { + if (req.url === "/sse") { + sseTransport = new SSEServerTransport("/messages", res); + await mcpServer.connect(sseTransport); + } else if (req.url?.startsWith("/messages") && req.method === "POST") { + if (sseTransport) { + await sseTransport.handlePostMessage(req, res); + } else { + res.writeHead(400).end("No SSE session"); + } + } else { + res.writeHead(404).end(); + } + }); + + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", resolve); + }); + const addr = httpServer.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + + try { + const workspaceDir = await makeTempDir("openclaw-bundle-mcp-sse-"); + const runtime = await createBundleMcpToolRuntime({ + workspaceDir, + cfg: { + mcp: { + servers: { + sseProbe: { + url: `http://127.0.0.1:${port}/sse`, + }, + }, + }, + }, + }); + + try { + expect(runtime.tools.map((tool) => tool.name)).toEqual(["sse_probe"]); + const result = await runtime.tools[0].execute("call-sse-probe", {}, undefined, undefined); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "FROM-SSE", + }); + expect(result.details).toEqual({ + mcpServer: "sseProbe", + mcpTool: "sse_probe", + }); + } finally { + await runtime.dispose(); + } + } finally { + httpServer.close(); + } + }); }); diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index bbe3aa200ae..71b6b617673 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -1,10 +1,12 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { logDebug, logWarn } from "../logger.js"; import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js"; +import { describeSseMcpServerLaunchConfig, resolveSseMcpServerLaunchConfig } from "./mcp-sse.js"; import { describeStdioMcpServerLaunchConfig, resolveStdioMcpServerLaunchConfig, @@ -16,10 +18,15 @@ type BundleMcpToolRuntime = { dispose: () => Promise; }; +/** Minimal interface shared by StdioClientTransport and SSEClientTransport. */ +type McpTransport = { + close: () => Promise; +}; + type BundleMcpSession = { serverName: string; client: Client; - transport: StdioClientTransport; + transport: McpTransport; detachStderr?: () => void; }; @@ -119,6 +126,53 @@ async function disposeSession(session: BundleMcpSession) { await session.transport.close().catch(() => {}); } +/** Try to create a stdio or SSE transport for the given raw server config. */ +function resolveTransport( + serverName: string, + rawServer: unknown, +): { + transport: McpTransport; + description: string; + detachStderr?: () => void; +} | null { + // Try stdio first (command-based servers). + const stdioLaunch = resolveStdioMcpServerLaunchConfig(rawServer); + if (stdioLaunch.ok) { + const transport = new StdioClientTransport({ + command: stdioLaunch.config.command, + args: stdioLaunch.config.args, + env: stdioLaunch.config.env, + cwd: stdioLaunch.config.cwd, + stderr: "pipe", + }); + return { + transport, + description: describeStdioMcpServerLaunchConfig(stdioLaunch.config), + detachStderr: attachStderrLogging(serverName, transport), + }; + } + + // Try SSE (url-based servers). + const sseLaunch = resolveSseMcpServerLaunchConfig(rawServer); + if (sseLaunch.ok) { + const headers: Record = { + ...sseLaunch.config.headers, + }; + const transport = new SSEClientTransport(new URL(sseLaunch.config.url), { + requestInit: Object.keys(headers).length > 0 ? { headers } : undefined, + }); + return { + transport, + description: describeSseMcpServerLaunchConfig(sseLaunch.config), + }; + } + + logWarn( + `bundle-mcp: skipped server "${serverName}" because ${stdioLaunch.reason} and ${sseLaunch.reason}.`, + ); + return null; +} + export async function createBundleMcpToolRuntime(params: { workspaceDir: string; cfg?: OpenClawConfig; @@ -144,20 +198,11 @@ export async function createBundleMcpToolRuntime(params: { try { for (const [serverName, rawServer] of Object.entries(loaded.mcpServers)) { - const launch = resolveStdioMcpServerLaunchConfig(rawServer); - if (!launch.ok) { - logWarn(`bundle-mcp: skipped server "${serverName}" because ${launch.reason}.`); + const resolved = resolveTransport(serverName, rawServer); + if (!resolved) { continue; } - const launchConfig = launch.config; - const transport = new StdioClientTransport({ - command: launchConfig.command, - args: launchConfig.args, - env: launchConfig.env, - cwd: launchConfig.cwd, - stderr: "pipe", - }); const client = new Client( { name: "openclaw-bundle-mcp", @@ -168,12 +213,12 @@ export async function createBundleMcpToolRuntime(params: { const session: BundleMcpSession = { serverName, client, - transport, - detachStderr: attachStderrLogging(serverName, transport), + transport: resolved.transport, + detachStderr: resolved.detachStderr, }; try { - await client.connect(transport); + await client.connect(resolved.transport); const listedTools = await listAllTools(client); sessions.push(session); for (const tool of listedTools) { @@ -193,7 +238,7 @@ export async function createBundleMcpToolRuntime(params: { label: tool.title ?? tool.name, description: tool.description?.trim() || - `Provided by bundle MCP server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}).`, + `Provided by bundle MCP server "${serverName}" (${resolved.description}).`, parameters: tool.inputSchema, execute: async (_toolCallId, input) => { const result = (await client.callTool({ @@ -210,7 +255,7 @@ export async function createBundleMcpToolRuntime(params: { } } catch (error) { logWarn( - `bundle-mcp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`, + `bundle-mcp: failed to start server "${serverName}" (${resolved.description}): ${String(error)}`, ); await disposeSession(session); } diff --git a/src/config/types.mcp.ts b/src/config/types.mcp.ts index 9d6b5e5a1d6..d3f20a1db69 100644 --- a/src/config/types.mcp.ts +++ b/src/config/types.mcp.ts @@ -1,10 +1,18 @@ export type McpServerConfig = { + /** Stdio transport: command to spawn. */ command?: string; + /** Stdio transport: arguments for the command. */ args?: string[]; + /** Environment variables passed to the server process (stdio) or as headers (SSE). */ env?: Record; + /** Working directory for stdio server. */ cwd?: string; + /** Alias for cwd. */ workingDirectory?: string; + /** SSE transport: URL of the remote MCP server (http or https). */ url?: string; + /** SSE transport: extra HTTP headers sent with every request. */ + headers?: Record; [key: string]: unknown; }; From 45970873267db3721a91b04b736752a1c59664b0 Mon Sep 17 00:00:00 2001 From: dhananjai1729 Date: Thu, 19 Mar 2026 17:15:10 +0530 Subject: [PATCH 2/4] fix: address review feedback - fix env JSDoc, warn on dropped headers, await server close --- src/agents/mcp-sse.ts | 13 ++++++++++--- src/agents/pi-bundle-mcp-tools.test.ts | 4 +++- src/agents/pi-bundle-mcp-tools.ts | 8 +++++++- src/config/types.mcp.ts | 2 +- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/agents/mcp-sse.ts b/src/agents/mcp-sse.ts index 3a3bc8f0b44..86c50759105 100644 --- a/src/agents/mcp-sse.ts +++ b/src/agents/mcp-sse.ts @@ -11,7 +11,10 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function toStringRecord(value: unknown): Record | undefined { +function toStringRecord( + value: unknown, + warnDropped?: (key: string, entry: unknown) => void, +): Record | undefined { if (!isRecord(value)) { return undefined; } @@ -23,13 +26,17 @@ function toStringRecord(value: unknown): Record | undefined { if (typeof entry === "number" || typeof entry === "boolean") { return [key, String(entry)] as const; } + warnDropped?.(key, entry); return null; }) .filter((entry): entry is readonly [string, string] => entry !== null); return entries.length > 0 ? Object.fromEntries(entries) : undefined; } -export function resolveSseMcpServerLaunchConfig(raw: unknown): SseMcpServerLaunchResult { +export function resolveSseMcpServerLaunchConfig( + raw: unknown, + options?: { onDroppedHeader?: (key: string, value: unknown) => void }, +): SseMcpServerLaunchResult { if (!isRecord(raw)) { return { ok: false, reason: "server config must be an object" }; } @@ -53,7 +60,7 @@ export function resolveSseMcpServerLaunchConfig(raw: unknown): SseMcpServerLaunc ok: true, config: { url, - headers: toStringRecord(raw.headers), + headers: toStringRecord(raw.headers, options?.onDroppedHeader), }, }; } diff --git a/src/agents/pi-bundle-mcp-tools.test.ts b/src/agents/pi-bundle-mcp-tools.test.ts index 8c58db3446d..9e2ecd5c5bb 100644 --- a/src/agents/pi-bundle-mcp-tools.test.ts +++ b/src/agents/pi-bundle-mcp-tools.test.ts @@ -253,7 +253,9 @@ describe("createBundleMcpToolRuntime", () => { await runtime.dispose(); } } finally { - httpServer.close(); + await new Promise((resolve, reject) => + httpServer.close((err) => (err ? reject(err) : resolve())), + ); } }); }); diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index 71b6b617673..9d3699e0472 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -153,7 +153,13 @@ function resolveTransport( } // Try SSE (url-based servers). - const sseLaunch = resolveSseMcpServerLaunchConfig(rawServer); + const sseLaunch = resolveSseMcpServerLaunchConfig(rawServer, { + onDroppedHeader: (key) => { + logWarn( + `bundle-mcp: server "${serverName}": header "${key}" has an unsupported value type and was ignored.`, + ); + }, + }); if (sseLaunch.ok) { const headers: Record = { ...sseLaunch.config.headers, diff --git a/src/config/types.mcp.ts b/src/config/types.mcp.ts index d3f20a1db69..fcc5297434e 100644 --- a/src/config/types.mcp.ts +++ b/src/config/types.mcp.ts @@ -3,7 +3,7 @@ export type McpServerConfig = { command?: string; /** Stdio transport: arguments for the command. */ args?: string[]; - /** Environment variables passed to the server process (stdio) or as headers (SSE). */ + /** Environment variables passed to the server process (stdio only). */ env?: Record; /** Working directory for stdio server. */ cwd?: string; From bd8edfd6582d7c506170b91fa4d81a0837d9e794 Mon Sep 17 00:00:00 2001 From: dhananjai1729 Date: Thu, 19 Mar 2026 17:50:33 +0530 Subject: [PATCH 3/4] fix: use SDK Transport type to satisfy client.connect() signature --- src/agents/pi-bundle-mcp-tools.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index 9d3699e0472..b7cae0ea2c5 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { logDebug, logWarn } from "../logger.js"; @@ -18,15 +19,10 @@ type BundleMcpToolRuntime = { dispose: () => Promise; }; -/** Minimal interface shared by StdioClientTransport and SSEClientTransport. */ -type McpTransport = { - close: () => Promise; -}; - type BundleMcpSession = { serverName: string; client: Client; - transport: McpTransport; + transport: Transport; detachStderr?: () => void; }; @@ -131,7 +127,7 @@ function resolveTransport( serverName: string, rawServer: unknown, ): { - transport: McpTransport; + transport: Transport; description: string; detachStderr?: () => void; } | null { From a7b2e95d4c337ed292b1adcf5a4f9277074f9a7e Mon Sep 17 00:00:00 2001 From: dhananjai1729 Date: Thu, 19 Mar 2026 18:09:52 +0530 Subject: [PATCH 4/4] fix: apply SSE auth headers to initial GET, redact URL credentials, warn on malformed headers --- src/agents/mcp-sse.ts | 41 ++++++++++++++++++++++++++++--- src/agents/pi-bundle-mcp-tools.ts | 19 +++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/agents/mcp-sse.ts b/src/agents/mcp-sse.ts index 86c50759105..ec8936dcffa 100644 --- a/src/agents/mcp-sse.ts +++ b/src/agents/mcp-sse.ts @@ -35,7 +35,10 @@ function toStringRecord( export function resolveSseMcpServerLaunchConfig( raw: unknown, - options?: { onDroppedHeader?: (key: string, value: unknown) => void }, + options?: { + onDroppedHeader?: (key: string, value: unknown) => void; + onMalformedHeaders?: (value: unknown) => void; + }, ): SseMcpServerLaunchResult { if (!isRecord(raw)) { return { ok: false, reason: "server config must be an object" }; @@ -56,17 +59,49 @@ export function resolveSseMcpServerLaunchConfig( reason: `only http and https URLs are supported, got ${parsed.protocol}`, }; } + // Warn if headers is present but not an object (e.g. a string or array). + let headers: Record | undefined; + if (raw.headers !== undefined && raw.headers !== null) { + if (!isRecord(raw.headers)) { + options?.onMalformedHeaders?.(raw.headers); + } else { + headers = toStringRecord(raw.headers, options?.onDroppedHeader); + } + } return { ok: true, config: { url, - headers: toStringRecord(raw.headers, options?.onDroppedHeader), + headers, }, }; } export function describeSseMcpServerLaunchConfig(config: SseMcpServerLaunchConfig): string { - return config.url; + try { + const parsed = new URL(config.url); + // Redact embedded credentials and query-token auth from log/description output. + if (parsed.username || parsed.password) { + parsed.username = parsed.username ? "***" : ""; + parsed.password = parsed.password ? "***" : ""; + } + for (const key of parsed.searchParams.keys()) { + const lower = key.toLowerCase(); + if ( + lower === "token" || + lower === "key" || + lower === "api_key" || + lower === "apikey" || + lower === "secret" || + lower === "access_token" + ) { + parsed.searchParams.set(key, "***"); + } + } + return parsed.toString(); + } catch { + return config.url; + } } export type { SseMcpServerLaunchConfig, SseMcpServerLaunchResult }; diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index b7cae0ea2c5..bae9160fcbc 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -155,13 +155,30 @@ function resolveTransport( `bundle-mcp: server "${serverName}": header "${key}" has an unsupported value type and was ignored.`, ); }, + onMalformedHeaders: () => { + logWarn( + `bundle-mcp: server "${serverName}": "headers" must be a JSON object; the value was ignored.`, + ); + }, }); if (sseLaunch.ok) { const headers: Record = { ...sseLaunch.config.headers, }; + const hasHeaders = Object.keys(headers).length > 0; const transport = new SSEClientTransport(new URL(sseLaunch.config.url), { - requestInit: Object.keys(headers).length > 0 ? { headers } : undefined, + // Apply headers to POST requests (tool calls, listTools, etc.). + requestInit: hasHeaders ? { headers } : undefined, + // Apply headers to the initial SSE GET handshake (required for auth). + eventSourceInit: hasHeaders + ? { + fetch: (url, init) => + fetch(url, { + ...init, + headers: { ...headers, ...(init?.headers as Record) }, + }), + } + : undefined, }); return { transport,