/** * External MCP — in-process proxy through the `/mcp/ext/` bridge. * * Instead of pointing each engine straight at a third-party MCP server (stdio * subprocess or remote URL), Clopen connects to that server ITSELF as an MCP * client and re-exposes its tools through a per-server endpoint on the same * Streamable-HTTP bridge the internal `clopen-mcp` tools already use. The * engine then talks only to Clopen (loopback + service token). * * Why proxy at all? Because Clopen owns the upstream client, it can: * 1. Read `tools/list` with a RAW JSON-RPC request — bypassing the MCP SDK's * `Client.listTools()` wrapper, which eagerly compiles an Ajv validator * for every tool's `outputSchema`. A single unresolvable `$ref` (e.g. * stitch's `#/$defs/ScreenInstance`) makes that wrapper throw, and the * engine CLIs' `discoverTools` swallow the error and surface ZERO tools — * so a "connected" server silently advertises nothing. * 2. Sanitize each tool before re-exposing it: drop the crash-prone * `outputSchema` and strip dangling `$ref`s from `inputSchema`. * 3. Inject the centrally-managed OAuth bearer / static API key once, on the * upstream hop — the engine→bridge hop only carries the service token. * * Net effect: external MCP servers behave identically on EVERY engine, and a * server with invalid tool schemas degrades to "valid tools still work" * instead of "whole server disappears". */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { ListToolsRequestSchema, CallToolRequestSchema, ListToolsResultSchema, CompatibilityCallToolResultSchema, type Tool } from '@modelcontextprotocol/sdk/types.js'; import { debug } from '$shared/utils/logger'; import { mcpServerQueries, permissionSetQueries, profileQueries } from '$backend/database/queries'; import type { EngineType } from '$shared/types/unified'; import { resolveServerRow } from './config'; import { parseToolOverrides, isToolExposed } from './tools'; // Import the PURE resolver (no `$backend/mcp` dependency) to avoid an // mcp → permissions/service → mcp import cycle. import { isToolAllowed, mergePermissions, pickEngineSet } from '$backend/permissions/resolve'; import type { ResolvedExternalServer } from './types'; /** Cap on how long we wait for an upstream server to complete its handshake. */ const UPSTREAM_CONNECT_TIMEOUT_MS = 30_000; /** * Open an MCP client connection to a single upstream external server (stdio * subprocess or remote HTTP/SSE). OAuth/API-key headers come from * `resolveServerRow`, which injects the Clopen-managed bearer. */ async function connectUpstream(s: ResolvedExternalServer): Promise { const client = new Client({ name: 'clopen-proxy', version: '1.0.0' }, { capabilities: {} }); if (s.transport === 'stdio') { if (!s.command) throw new Error(`External server "${s.slug}" is stdio but has no command`); // Merge over the SDK's safe default env (PATH, HOME, …) so `npx`/`uvx` // resolve — passing only `s.env` would strip the inherited PATH. const transport = new StdioClientTransport({ command: s.command, args: s.args, env: { ...getDefaultEnvironment(), ...s.env }, stderr: 'ignore' }); await client.connect(transport, { timeout: UPSTREAM_CONNECT_TIMEOUT_MS }); return client; } if (!s.url) throw new Error(`External server "${s.slug}" is ${s.transport} but has no URL`); const url = new URL(s.url); const requestInit = Object.keys(s.headers).length > 0 ? { headers: s.headers } : undefined; const transport = s.transport === 'sse' ? new SSEClientTransport(url, { requestInit }) : new StreamableHTTPClientTransport(url, { requestInit }); await client.connect(transport, { timeout: UPSTREAM_CONNECT_TIMEOUT_MS }); return client; } /** * Strip every `$ref` that points at a `$defs`/`definitions` entry the schema * doesn't actually declare. Such dangling refs are what make a downstream * Ajv `compile()` throw `MissingRefError`; replacing the offending node with an * empty (permissive) schema keeps the rest of the shape intact and validatable. * * Best-effort and shallow on cost: walks the tree once, mutating a deep clone. */ function repairSchema(schema: T): T { if (schema == null || typeof schema !== 'object') return schema; const root = schema as Record; const defs = new Set(); for (const key of ['$defs', 'definitions'] as const) { const bucket = root[key]; if (bucket && typeof bucket === 'object') { for (const name of Object.keys(bucket as Record)) defs.add(`${key}/${name}`); } } const walk = (node: unknown): unknown => { if (Array.isArray(node)) return node.map(walk); if (node && typeof node === 'object') { const obj = node as Record; const ref = obj.$ref; if (typeof ref === 'string' && ref.startsWith('#/')) { const target = ref.slice(2); // strip "#/" if (!defs.has(target)) { // Dangling local ref → drop it, leaving any sibling keywords. const { $ref, ...rest } = obj; void $ref; return walk(rest); } } const out: Record = {}; for (const [k, v] of Object.entries(obj)) out[k] = walk(v); return out; } return node; }; return walk(root) as T; } /** * Re-expose an upstream tool to engines: keep its identity + input schema, * repair dangling `$ref`s, and DROP `outputSchema` entirely. `outputSchema` is * the field whose unresolvable `$ref`s crash the engines' eager Ajv * compilation, and non-Claude engines don't rely on it — dropping it is the * safest way to make every server's tools usable. */ function sanitizeTool(t: Tool): Tool { const { outputSchema: _drop, inputSchema, ...rest } = t; void _drop; return { ...rest, inputSchema: repairSchema(inputSchema ?? { type: 'object' as const }) } as Tool; } /** Read every page of an upstream `tools/list` via RAW requests (no Ajv caching). */ async function listAllToolsRaw(client: Client): Promise { const tools: Tool[] = []; let cursor: string | undefined; do { const res = await client.request( { method: 'tools/list', params: cursor ? { cursor } : {} }, ListToolsResultSchema ); tools.push(...res.tools); cursor = res.nextCursor; } while (cursor); return tools; } /** A bound proxy server plus the cleanup that tears down its upstream client. */ export interface ExternalProxy { server: Server; close: () => Promise; } /** * Build a low-level MCP `Server` that proxies one external server identified by * `slug`. `tools/list` returns the upstream's sanitized tools; `tools/call` * forwards verbatim. Throws if the slug isn't an enabled external server or the * upstream handshake fails (the caller surfaces this as a per-server failure, * never taking down the whole bridge). * * `engine` identifies the caller (each engine's config points at * `/mcp/ext/?engine=`). It is the SINGLE enforcement point for * per-tool exposure: `tools/list` hides tools the user disabled for this engine * and `tools/call` refuses them — so filtering behaves identically on every * engine, even those whose SDK has no tool-allowlist config. */ export async function createExternalProxyServer(slug: string, engine?: EngineType): Promise { const row = mcpServerQueries.getBySlug(slug); if (!row || row.source === 'internal') throw new Error(`Unknown external MCP server: ${slug}`); // A globally-disabled server is still servable when a Profile references it — an // active profile is the source of truth for which connectors run, overriding the // enable toggle (the config builder only emits its bridge URL in that case). if (row.is_enabled !== 1 && !profileQueries.isArtifactReferenced('mcp', slug)) { throw new Error(`External MCP server is disabled: ${slug}`); } const resolved = resolveServerRow(row); const overrides = parseToolOverrides(row.tool_overrides); const client = await connectUpstream(resolved); // Global tool-permission policy for this engine (Settings → Permissions). // MCP tools carry no permission hook on some engines (e.g. OpenCode only gates // edit/bash/webfetch), so the bridge is the single reliable enforcement point // for MCP deny/allow across every engine — it filters the tool out before the // engine ever sees it. Rules are matched against the canonical engine-facing // name `mcp____` (the identity the Permissions UI uses). // Global scope only: the bridge has no session/project context. const permissions = engine ? mergePermissions(pickEngineSet(permissionSetQueries.getGlobal(), engine), undefined) : null; const permitted = (toolName: string): boolean => !permissions || isToolAllowed(permissions, `mcp__${resolved.namespace}__${toolName}`); const server = new Server( { name: `clopen-ext-${slug}`, version: '1.0.0' }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => { const all = (await listAllToolsRaw(client)).map(sanitizeTool); const tools = all.filter(t => isToolExposed(overrides, t.name, engine) && permitted(t.name)); const hidden = all.length - tools.length; debug.log('mcp', `🔌 Proxy ${slug}${engine ? ` (${engine})` : ''}: serving ${tools.length} tool(s)${hidden > 0 ? `, ${hidden} hidden` : ''}`); return { tools }; }); server.setRequestHandler(CallToolRequestSchema, async (req) => { // Defense in depth: a disabled tool should never have been advertised, but // refuse the call outright in case the engine cached an older tool list. if (!isToolExposed(overrides, req.params.name, engine)) { return { content: [{ type: 'text' as const, text: `Tool ${req.params.name} is disabled for this engine.` }], isError: true }; } // Same guard for a permission-denied tool (Settings → Permissions). if (!permitted(req.params.name)) { return { content: [{ type: 'text' as const, text: `Tool ${req.params.name} is blocked by Clopen permission policy.` }], isError: true }; } // Forward verbatim. CompatibilityCallToolResultSchema tolerates both the // modern and 2024-10-07 result shapes; we never cached an output // validator (we bypass listTools), so no schema check runs here. return await client.request( { method: 'tools/call', params: req.params }, CompatibilityCallToolResultSchema ); }); const close = async () => { try { await client.close(); } catch (error) { debug.warn('mcp', `Proxy ${slug}: error closing upstream client:`, error); } }; return { server, close }; } /** * Open a short-lived upstream connection for a slug (no bridge, no session). * Used by the Settings tool panel / inspector — reuses the same connect + * credential path as the bridge but tears the client down immediately. Unlike * the bridge it does NOT require the server to be enabled, so tools can be * inspected before the server is switched on. */ async function withUpstreamClient(slug: string, fn: (client: Client) => Promise): Promise { const row = mcpServerQueries.getBySlug(slug); if (!row || row.source === 'internal') throw new Error(`Unknown external MCP server: ${slug}`); const client = await connectUpstream(resolveServerRow(row)); try { return await fn(client); } finally { client.close().catch(error => debug.warn('mcp', `Introspect ${slug}: error closing client:`, error)); } } /** List a server's sanitized tools live (unfiltered) — for the Settings tool panel. */ export async function listExternalServerTools(slug: string): Promise { return withUpstreamClient(slug, async (client) => (await listAllToolsRaw(client)).map(sanitizeTool)); } /** * Call a single tool live and return its raw MCP result — for the inspector. * A tool that fails returns an `isError` result (not a throw), so the inspector * can render the error content; connection/handshake failures still throw. */ export async function callExternalServerTool(slug: string, toolName: string, args: unknown): Promise { return withUpstreamClient(slug, async (client) => client.request( { method: 'tools/call', params: { name: toolName, arguments: (args ?? {}) as Record } }, CompatibilityCallToolResultSchema ) ); }