{"version":3,"sources":["../../../src/server/mcp.ts","../../../src/server/net-guard.ts"],"sourcesContent":["/**\n * MCP (Model Context Protocol) tool wiring for the chat handler.\n *\n * Lets an agent expose tools from one or more remote MCP servers — both a curated\n * catalog of supported integrations and arbitrary custom servers a developer (or\n * their end-user) connects. Each configured server is connected per request, its\n * tools merged into the turn's tool set, and the client closed when the request\n * settles.\n *\n * This plugs into the handler's existing `buildTools` seam: `connectMcpTools`\n * returns `{ tools, cleanup }` (the BuiltTools shape), so the handler's\n * once-only teardown closes every MCP client on finish / error / abort. No\n * handler changes are needed — MCP is just a tool source.\n *\n *   createChatHandler({\n *     buildTools: async (ctx) => connectMcpTools(await resolveAgentMcpServers(ctx)),\n *   })\n *\n * Transport: HTTP (StreamableHTTP) or SSE remote servers only — stdio is a local\n * subprocess and has no place in a hosted multi-tenant backend. Per-server\n * headers carry auth (e.g. an end-user's token). Connection failures are\n * isolated: one bad server never blocks the others or the turn.\n *\n * SECURITY:\n *   • SSRF — every server URL is validated with the shared `assertPublicHttpUrl`\n *     guard (http/https only; private/loopback/link-local/cloud-metadata hosts\n *     blocked, including hostnames that RESOLVE to them) BEFORE the client\n *     connects, so a user- or developer-supplied URL can't be used to reach the\n *     internal network. Pass `{ allowPrivateHosts: true }` only when you\n *     intentionally connect to an internal MCP server you control.\n *   • Untrusted output — a remote server's tool descriptions AND results are\n *     UNTRUSTED model input; a malicious/compromised server can attempt prompt\n *     injection through them. The handler keeps the operator's system prompt\n *     authoritative and tool names are namespaced per server, but operators\n *     should still only wire MCP servers they trust for a given agent.\n */\n\nimport { createMCPClient } from '@ai-sdk/mcp';\nimport type { ToolSet } from 'ai';\nimport { assertPublicHttpUrl } from './net-guard';\n\nexport type McpTransport = 'http' | 'sse';\n\nexport interface McpServerConfig {\n  /** Stable id (also used to namespace this server's tool names). */\n  id: string;\n  /** Human label (catalog name or user-given name). */\n  label?: string;\n  /** Remote endpoint. */\n  url: string;\n  /** http = StreamableHTTP (default), sse = Server-Sent Events. */\n  transport?: McpTransport;\n  /** Auth / custom headers (e.g. { Authorization: 'Bearer …' }). */\n  headers?: Record<string, string>;\n  /**\n   * Prefix this server's tool names with `${id}_` to avoid collisions when\n   * several servers expose a tool of the same name. Default true.\n   */\n  namespaceTools?: boolean;\n}\n\nexport interface ConnectedMcp {\n  /** Merged tools across all servers that connected successfully. */\n  tools: ToolSet;\n  /** Close every connected client. Idempotent; safe to call once. */\n  cleanup: () => Promise<void>;\n  /** Per-server connection outcome (for logging/telemetry). */\n  results: { id: string; ok: boolean; toolCount: number; error?: string }[];\n}\n\n// Prefix tool names so two servers can both expose e.g. \"search\" without clobber.\n// `client.tools()` returns a tool record whose value type is more specific than\n// ToolSet's; keep it generic and re-key, casting back to ToolSet at the boundary\n// (the values are structurally valid tools the model loop accepts).\nfunction namespaced<T extends Record<string, unknown>>(tools: T, prefix: string): ToolSet {\n  const out: Record<string, unknown> = {};\n  for (const [name, tool] of Object.entries(tools)) out[`${prefix}_${name}`] = tool;\n  return out as ToolSet;\n}\n\n/**\n * Connect every configured MCP server, merge their tools, and return a BuiltTools\n * `{ tools, cleanup }`. Best-effort: a server that fails to connect is skipped\n * (recorded in `results`) — it never throws into the turn. `cleanup` closes all\n * clients and is safe to call exactly once (the handler guarantees that).\n */\nexport interface ConnectMcpOptions {\n  /**\n   * Allow MCP servers on PRIVATE/internal hosts — skips the SSRF guard's\n   * private-range checks (the http/https scheme check still applies). OFF by\n   * default, the safe multi-tenant posture where server URLs may be developer-\n   * or end-user-supplied. Enable only when you INTENTIONALLY connect to internal\n   * MCP servers you control.\n   */\n  allowPrivateHosts?: boolean;\n  /** Trusted server-side transport override (e.g. scoped hosted HTTP + timeout). */\n  fetch?: typeof fetch;\n  /** Abort discovery/execution and close connections, never remote workspaces. */\n  signal?: AbortSignal;\n}\n\nexport async function connectMcpTools(\n  servers: McpServerConfig[],\n  opts: ConnectMcpOptions = {},\n): Promise<ConnectedMcp> {\n  const clients: { close: () => Promise<void> }[] = [];\n  const results: ConnectedMcp['results'] = [];\n  let tools: ToolSet = {};\n  let cleaned = false;\n  let cleanupPromise: Promise<void> | undefined;\n  const cleanup = (): Promise<void> => {\n    if (cleanupPromise) return cleanupPromise;\n    cleaned = true;\n    opts.signal?.removeEventListener('abort', onAbort);\n    cleanupPromise = Promise.all(clients.map((client) => client.close())).then(() => {});\n    return cleanupPromise;\n  };\n  const onAbort = () => { void cleanup(); };\n  if (opts.signal?.aborted) await cleanup();\n  else opts.signal?.addEventListener('abort', onAbort, { once: true });\n  const transportFetch: typeof fetch | undefined = opts.fetch || opts.signal\n    ? (input, init) => (opts.fetch ?? globalThis.fetch)(input, {\n        ...init,\n        ...(opts.signal ? { signal: init?.signal\n          ? AbortSignal.any([opts.signal, init.signal]) : opts.signal } : {}),\n      })\n    : undefined;\n\n  // Connect in parallel; isolate failures per server.\n  await Promise.all(\n    (servers ?? []).map(async (server) => {\n      if (!server?.url || !server?.id) {\n        results.push({ id: server?.id ?? '(unknown)', ok: false, toolCount: 0, error: 'missing id/url' });\n        return;\n      }\n      // SSRF guard: reject non-http(s) schemes and private/internal/metadata\n      // endpoints BEFORE the client dials a (possibly user-supplied) URL.\n      try {\n        await assertPublicHttpUrl(server.url, { allowPrivate: opts.allowPrivateHosts });\n      } catch (err) {\n        results.push({\n          id: server.id,\n          ok: false,\n          toolCount: 0,\n          error: err instanceof Error ? err.message : 'blocked url',\n        });\n        return;\n      }\n      let resource: { close: () => Promise<void> } | undefined;\n      try {\n        opts.signal?.throwIfAborted();\n        if (cleaned) throw new Error('MCP connection already closed');\n        const client = await createMCPClient({\n          transport: {\n            type: server.transport ?? 'http',\n            url: server.url,\n            ...(server.headers ? { headers: server.headers } : {}),\n            ...(transportFetch ? { fetch: transportFetch } : {}),\n          },\n        });\n        let closePromise: Promise<void> | undefined;\n        resource = { close: () => closePromise ??= Promise.resolve().then(() => client.close()).catch(() => {\n          // Never log a transport error that may include auth headers or URLs.\n          console.error('[chat-widget] MCP client close failed');\n        }) };\n        clients.push(resource);\n        // An abort can win while createMCPClient is resolving. A late connection\n        // still gets its one close; it cannot escape the earlier cleanup pass.\n        if (cleaned || opts.signal?.aborted) {\n          await resource.close();\n          throw new Error('MCP connection aborted');\n        }\n        const serverTools = await client.tools();\n        opts.signal?.throwIfAborted();\n        const merged =\n          server.namespaceTools === false ? (serverTools as ToolSet) : namespaced(serverTools, server.id);\n        tools = { ...tools, ...merged };\n        results.push({ id: server.id, ok: true, toolCount: Object.keys(serverTools).length });\n      } catch (err) {\n        // Tool discovery failure must not retain a connected client until some\n        // caller remembers to clean up a failed server.\n        await resource?.close();\n        results.push({\n          id: server.id,\n          ok: false,\n          toolCount: 0,\n          error: err instanceof Error ? err.message : String(err),\n        });\n      }\n    }),\n  );\n\n  return { tools: cleaned ? {} : tools, cleanup, results };\n}\n","/**\n * Shared SSRF network guard — the single source of truth for which hosts/IPs\n * the server may connect OUT to. Used by the knowledge ingest loader\n * (`knowledge/loaders.ts`) and the MCP connector (`mcp.ts`) so the private-range\n * block-list can never drift between the two outbound-fetch surfaces.\n *\n * Node-only (uses `node:dns` / `node:net`); server surfaces import it behind\n * `server-only`.\n */\n\nimport 'server-only';\nimport { lookup } from 'node:dns/promises';\nimport { isIP } from 'node:net';\n\n/** True if an IP string is private/loopback/link-local/unique-local/metadata. */\nexport function isBlockedIp(ip: string): boolean {\n  const v = isIP(ip);\n  if (v === 4) {\n    const o = ip.split('.').map(Number);\n    if (o[0] === 10) return true; // 10/8\n    if (o[0] === 127) return true; // loopback\n    if (o[0] === 0) return true; // 0/8\n    if (o[0] === 169 && o[1] === 254) return true; // link-local incl. 169.254.169.254 metadata\n    if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // 172.16/12\n    if (o[0] === 192 && o[1] === 168) return true; // 192.168/16\n    if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return true; // CGNAT 100.64/10\n    if (o[0] >= 224) return true; // multicast / reserved\n    return false;\n  }\n  if (v === 6) {\n    const lower = ip.toLowerCase();\n    if (lower === '::1' || lower === '::') return true; // loopback / unspecified\n    if (lower.startsWith('fe80')) return true; // link-local\n    if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // unique-local\n    if (lower.startsWith('::ffff:')) return isBlockedIp(lower.slice(7)); // v4-mapped\n    return false;\n  }\n  return false;\n}\n\n/** Hostnames that are never fetchable regardless of resolution. */\nexport function isBlockedHostname(host: string): boolean {\n  const h = host.toLowerCase();\n  return (\n    h === 'localhost' ||\n    h.endsWith('.localhost') ||\n    h.endsWith('.internal') ||\n    h.endsWith('.local') ||\n    h === 'metadata.google.internal'\n  );\n}\n\nexport interface AssertPublicUrlOptions {\n  /**\n   * Skip the private/internal checks (scheme is still enforced). For operators\n   * who intentionally connect to an INTERNAL host (e.g. a self-hosted MCP server\n   * on a private network). Off by default — the safe multi-tenant posture.\n   */\n  allowPrivate?: boolean;\n}\n\n/**\n * Assert that a URL is safe for the server to connect OUT to, and return the\n * parsed `URL`. Rejects non-http(s) schemes, blocked internal hostnames, literal\n * private/loopback/link-local/metadata IPs, and hostnames that RESOLVE to any\n * such IP.\n *\n * NOTE: this validates before the connect (DNS is resolved and every returned\n * address checked). Unlike the ingest loader — which additionally PINS the\n * vetted IP to the socket via undici's connector — a caller that hands the URL\n * to a client doing its own DNS (e.g. the MCP transport) has a residual\n * DNS-rebinding TOCTOU window. It still blocks the overwhelming majority of SSRF\n * (literal IPs, metadata endpoints, hosts that resolve internal) and is a hard\n * prerequisite before connecting to a user/developer-supplied endpoint.\n */\nexport async function assertPublicHttpUrl(\n  rawUrl: string,\n  opts: AssertPublicUrlOptions = {},\n): Promise<URL> {\n  let url: URL;\n  try {\n    url = new URL(rawUrl);\n  } catch {\n    throw new Error(`Invalid URL: ${rawUrl}`);\n  }\n  if (url.protocol !== 'http:' && url.protocol !== 'https:') {\n    throw new Error(`Blocked URL scheme (only http/https allowed): ${url.protocol}`);\n  }\n  if (opts.allowPrivate) return url;\n\n  // `url.hostname` keeps brackets for IPv6 literals (e.g. \"[::1]\") — strip them.\n  const host = url.hostname.replace(/^\\[/, '').replace(/\\]$/, '');\n  if (isBlockedHostname(host)) {\n    throw new Error(`Blocked internal hostname: ${host}`);\n  }\n  // Literal IP: validate directly (no resolution).\n  if (isIP(host) !== 0) {\n    if (isBlockedIp(host)) throw new Error(`Blocked private/internal IP: ${host}`);\n    return url;\n  }\n  // Hostname: resolve and reject if ANY resolved address is non-public.\n  let results: Array<{ address: string; family: number }>;\n  try {\n    results = await lookup(host, { all: true });\n  } catch {\n    throw new Error(`DNS resolution failed for ${host}`);\n  }\n  if (results.length === 0) throw new Error(`DNS resolution failed for ${host}`);\n  for (const r of results) {\n    if (isBlockedIp(r.address)) {\n      throw new Error(`${host} resolves to a blocked IP (${r.address})`);\n    }\n  }\n  return url;\n}\n"],"mappings":"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","names":[]}