@builder.io/dev-tools
Version:
Builder.io Visual CMS Devtools
274 lines (273 loc) • 12 kB
TypeScript
/**
* 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[]>;