UNPKG

@builder.io/dev-tools

Version:

Builder.io Visual CMS Devtools

274 lines (273 loc) 12 kB
/** * Local MCP (Model Context Protocol) Client Manager for stdio transport * * This module manages local MCP servers that run as child processes using stdio transport. * Local MCP servers are defined in mcp.json configuration file in the working directory. * * Tool naming follows the same convention as remote MCPs: * - Tools are prefixed with server name: `mcp__servername__toolname` * - This prevents conflicts with built-in tools and other MCP tools */ import type { ContentMessageItemImage, ContentMessageItemText, MCPClientStatus, MCPServerConfig } from "#ai-utils"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { DevToolsSys } from "../core"; import type { AuthToolCandidate } from "#vcp-common/mcp"; /** * Default connect timeout in milliseconds for remote MCP servers. * Override per-server via `connectTimeoutMs` in mcp.json. */ export declare const DEFAULT_MCP_CONNECT_TIMEOUT_MS = 10000; export interface MCPServerStdioDefinition { name: string; command: string; args?: string[]; env?: Record<string, string>; envFile?: string; retries?: number; /** When true, the server is registered but not connected to. */ disabled?: boolean; /** * Where this server was discovered. Drives precedence on name collision: * built-in/fusion (unset) > `project` > `user` > `plugin`. Set by the * loader, not by the config file. */ scope?: "project" | "user" | "plugin"; /** * Name of the plugin that contributed this server, if any. Set by the * plugin loader (Phase 2); always `undefined` for project-level and * user-level standalone configs (Phase 1). */ pluginName?: string; /** * Absolute path of the `mcp.json` / plugin manifest this entry was * loaded from. `undefined` for built-in/fusion configs. Surfaced to the * `/mcp` slash command so the UI can render "Config location: ...". */ configFilePath?: string; } /** * Optional OAuth configuration for an `mcp.json` remote-MCP entry. * * The actual OAuth flow lives in {@link LocalOAuthProvider} * (`mcp-oauth-local.ts`); this type is the JSON surface — just the bits * the user can set in `mcp.json`. All fields are optional: a remote MCP * with no `oauth` block at all uses anonymous / static-bearer auth as it * does today; an empty `{}` enables the OAuth flow with all-defaults * (DCR, ephemeral loopback port). */ export interface MCPServerOAuthConfig { /** * Pre-registered DCR client ID. Use only for IdPs that don't support * dynamic client registration — most DCR-capable IdPs (Linear, GitHub, * Notion) work with the empty default. */ clientId?: string; /** * Pre-registered DCR client secret. Optional even when `clientId` is set * (public clients have no secret). */ clientSecret?: string; /** * Fixed loopback redirect port, for IdPs that require a pre-registered * `redirect_uri`. Default: kernel-assigned ephemeral port. */ redirectPort?: number; /** * Phase 4 setting — type-only declaration here; the connect logic * doesn't act on it yet. When true, locally-obtained credentials get * pushed to the server registry on next sync (enables server-driven * codegen to use the same connection). */ syncToServer?: boolean; } export interface MCPServerRemoteDefinition { name: string; type: "http" | "sse"; url: string; headers?: Record<string, string>; sessionId?: string; envFile?: string; /** * Maximum total time (ms) to wait for the initial connect handshake. * Defaults to {@link DEFAULT_MCP_CONNECT_TIMEOUT_MS}. */ connectTimeoutMs?: number; retries?: number; /** * Optional OAuth configuration. See {@link MCPServerOAuthConfig}. The * Phase 1c provider in `mcp-oauth-local.ts` consumes these fields; * Phase 2b will plumb the provider into `connectRemoteMCP` so a 401 * automatically triggers the OAuth flow. */ oauth?: MCPServerOAuthConfig; /** When true, the server is registered but not connected to. */ disabled?: boolean; /** * Where this server was discovered. Drives precedence on name collision: * built-in/fusion (unset) > `project` > `user` > `plugin`. Set by the * loader, not by the config file. */ scope?: "project" | "user" | "plugin"; /** * Name of the plugin that contributed this server, if any. Set by the * plugin loader (Phase 2); always `undefined` for project-level and * user-level standalone configs (Phase 1). */ pluginName?: string; /** See {@link MCPServerStdioDefinition.configFilePath}. */ configFilePath?: string; } /** * Build privacy-safe metadata about a sessionId for logging. * Never logs the sessionId itself or any substring of it. */ export declare function describeSessionIdForLog(sessionId: string): { length: number; looksLikeJWT: boolean; hasEnvPlaceholder: boolean; }; /** * Fallback heuristic for detecting 401/Unauthorized errors when the SDK's * `UnauthorizedError` instance has been wrapped or has lost its prototype * across module boundaries (e.g. dual ESM/CJS, JSON-serialized re-throws). * Prefer `instanceof UnauthorizedError`; only use this as a last resort. */ export declare function isLikelyUnauthorizedError(error: unknown): boolean; export type MCPServerDefinition = MCPServerStdioDefinition | MCPServerRemoteDefinition; export interface MCPConfig { mcpServers: Record<string, Omit<MCPServerStdioDefinition, "name"> | Omit<MCPServerRemoteDefinition, "name">>; } export interface LocalMCPClient { client: Client | undefined; transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | undefined; status: MCPClientStatus; serverName: string; normalizedServerName: string; serverType: "stdio" | "http" | "sse"; command?: string; url?: string; resources?: { uri: string; name?: string; description?: string; mimeType?: string; }[]; /** * True when the connection failed with a 401/Unauthorized — the LLM-driven * `__authenticate` flow (Phase 2b) routes through `getAuthRequiredServers()` * to surface a synthetic auth tool. Set in the connect catch path; not * persisted across manager rebuilds. */ authRequired?: boolean; /** * OAuth config from `mcp.json` (Phase 1c type). Carried on the client * record so the LLM-driven auth flow (Phase 2b) can construct a * `LocalOAuthProvider` with pinned client credentials when running. */ oauth?: MCPServerOAuthConfig; /** * Original remote server definition. Stored on auth-required clients so * `reconnectServer` can rebuild the transport after OAuth completes * without re-loading `mcp.json`. Only populated for http/sse servers * that failed the initial connect — stdio servers can't reconnect via * this path. */ serverDef?: MCPServerRemoteDefinition; } export interface LocalMCPClientManager { clients: LocalMCPClient[]; listTools: () => { name: string; description?: string; inputSchema?: any; serverName: string; }[]; callTool: (name: string, args?: any, signal?: AbortSignal) => Promise<{ content: (ContentMessageItemText | ContentMessageItemImage)[]; isError?: boolean; }>; getResources: (serverName?: string) => Array<{ uri: string; name?: string; description?: string; mimeType?: string; serverName: string; text?: string; }>; getStatus: () => Record<string, MCPClientStatus>; /** * Returns the list of CLI-managed MCPs that need OAuth before they can * be used. Used by `code-tools.ts` to look up the matching server config * when the LLM calls a `mcp__<n>__authenticate` synthetic tool, and by * `mcp list` for inventory display. The synthetic auth tools themselves * are surfaced through `listTools()` (alongside real MCP tools) — there * is no separate wire field for auth-required servers; they round-trip * back through the existing `localMCPTools` execution path. * * Today this is just the set of remote MCPs whose connection failed * with a 401. Discovery is deferred until the LLM actually invokes the * auth tool (spec §B "Mechanics"). */ getAuthRequiredServers: () => AuthToolCandidate[]; /** * Retry connecting a single previously-auth-required server after the * LLM-driven OAuth flow completes. Re-runs `connectRemoteMCP` (which * picks up the freshly-stored bearer token) and, on success, fetches * tools/resources for the server and updates the manager's caches so * the next `listTools()` call returns real tools instead of the * synthetic `__authenticate` tool. * * Returns a summary of the attempt for the caller (typically * `code-tools.ts`) to surface as a completion event. */ reconnectServer: (serverName: string, signal?: AbortSignal) => Promise<{ success: boolean; toolsAdded: number; message?: string; }>; cleanup: () => Promise<void>; } /** * Map a server definition to the `source` discriminator surfaced on * `MCPClientStatus`. Built-in / fusion-config servers have no `scope` * (the loader doesn't set one), plugin-contributed servers always have * `scope: "plugin"`, and everything else is a CLI-managed local server. */ export declare function getLocalMCPSource(server: MCPServerDefinition): "local" | "plugin" | "builtin"; /** * Create a local MCP client manager from server definitions * * @param servers - MCP server definitions parsed from `mcp.json`. * @param sys - DevTools system bindings. * @param workingDirectory - The working directory used to resolve * relative paths (env files, stdio cwd). * @param signal - Optional abort signal for the connection batch. */ export declare function createLocalMCPClientManager(servers: MCPServerDefinition[], sys: DevToolsSys, workingDirectory: string, signal?: AbortSignal): Promise<LocalMCPClientManager>; /** * Apply environment variable substitution to MCP server configuration * This is separated from loadMCPConfig to allow easy unit testing */ export declare function applyEnvSubstitution(serverConfig: Omit<MCPServerStdioDefinition, "name">, name: string, baseEnv: Record<string, string | undefined>, envFileVars: Record<string, string>): MCPServerStdioDefinition; /** * Apply environment variable substitution to remote MCP server configuration * This is separated from loadMCPConfig to allow easy unit testing */ export declare function applyEnvSubstitutionRemote(serverConfig: Omit<MCPServerRemoteDefinition, "name">, name: string, baseEnv: Record<string, string | undefined>, envFileVars: Record<string, string>): MCPServerRemoteDefinition; /** * Discover and load MCP configuration from working directory and fusionConfig * Servers from fusionConfig will be merged with servers from mcp.json * If a server with the same name exists in both, fusionConfig takes precedence * Supports both stdio (command-based) and remote (http/sse) server definitions * * Precedence (lowest → highest): * 1. ~/.builder/mcp.json (user-level) * 2. <workingDirectory>/mcp.json (project-level) * 3. serverConfigs from fusionConfig * * @param homeDir Override for the user's home directory (for testing). */ export declare function loadMCPConfig(sys: DevToolsSys, workingDirectory: string, serverConfigs: MCPServerConfig, autoImportLocalMCPs: boolean, signal?: AbortSignal, homeDir?: string): Promise<MCPServerDefinition[]>;