From 3fba4bcb03af2652058bdec5de3264e09a45eced Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 10 May 2026 22:19:46 +0200 Subject: [PATCH] refactor(mcp): move MCP connection manager to packages/coding-agent/src/core/mcp/ - Create config.ts with McpServerConfig types and readMcpConfigs/getServerConfig - Create auth.ts with buildHttpTransportOpts and createCliOAuthProvider - Create connection-manager.ts with McpConnectionManager class - Create index.ts re-exporting the public API - Export McpConnectionManager and helpers from @singularity-forge/coding-agent - Rewrite mcp-client extension as thin wrapper using McpConnectionManager - Rewrite auth.js as re-export shim from @singularity-forge/coding-agent - Update test to import buildHttpTransportOpts from @singularity-forge/coding-agent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/coding-agent/src/core/mcp/auth.ts | 111 +++ packages/coding-agent/src/core/mcp/config.ts | 105 ++ .../src/core/mcp/connection-manager.ts | 328 ++++++ packages/coding-agent/src/core/mcp/index.ts | 20 + packages/coding-agent/src/index.ts | 22 + src/resources/extensions/mcp-client/auth.js | 108 +- src/resources/extensions/mcp-client/index.js | 936 +++++++----------- src/tests/mcp-client-oauth.test.ts | 2 +- 8 files changed, 961 insertions(+), 671 deletions(-) create mode 100644 packages/coding-agent/src/core/mcp/auth.ts create mode 100644 packages/coding-agent/src/core/mcp/config.ts create mode 100644 packages/coding-agent/src/core/mcp/connection-manager.ts create mode 100644 packages/coding-agent/src/core/mcp/index.ts diff --git a/packages/coding-agent/src/core/mcp/auth.ts b/packages/coding-agent/src/core/mcp/auth.ts new file mode 100644 index 000000000..1fc3457b9 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/auth.ts @@ -0,0 +1,111 @@ +/** + * auth.ts — MCP HTTP transport authentication helpers. + * + * Purpose: build transport options (static headers or OAuthClientProvider) + * from MCP server config entries so HTTP transports authenticate correctly. + * + * Consumer: McpConnectionManager.getOrConnect() for HTTP transport setup. + */ +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthConfig } from "./config.js"; + +export interface AuthConfig { + headers?: Record; + oauth?: OAuthConfig; +} + +export interface HttpTransportOptions { + authProvider?: OAuthClientProvider; + requestInit?: { headers: Record }; +} + +function resolveEnvValue(value: string): string { + return value.replace( + /\$\{([^}]+)\}/g, + (_match: string, varName: string) => process.env[varName] ?? "", + ); +} + +function resolveHeaders(raw: Record): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(raw)) { + resolved[key] = typeof value === "string" ? resolveEnvValue(value) : value; + } + return resolved; +} + +/** + * Create a minimal OAuthClientProvider for CLI / headless use. + * + * Purpose: support pre-configured client credentials and in-memory token + * storage for server-to-server and pre-authed OAuth scenarios. + * + * Consumer: buildHttpTransportOpts when the server config has an oauth block. + */ +export function createCliOAuthProvider(config: OAuthConfig): OAuthClientProvider { + let storedTokens: Parameters[0] | undefined; + let storedCodeVerifier = ""; + return { + get redirectUrl() { + return config.redirectUrl ?? "http://localhost:0/callback"; + }, + get clientMetadata() { + return { + redirect_uris: [config.redirectUrl ?? "http://localhost:0/callback"], + client_name: "sf", + ...(config.scopes ? { scope: config.scopes.join(" ") } : {}), + }; + }, + clientInformation() { + return { + client_id: config.clientId, + ...(config.clientSecret ? { client_secret: config.clientSecret } : {}), + }; + }, + tokens() { + return storedTokens; + }, + saveTokens(tokens) { + storedTokens = tokens; + }, + redirectToAuthorization(authorizationUrl) { + console.error( + `[MCP OAuth] Authorization required. Visit:\n ${authorizationUrl.toString()}`, + ); + }, + saveCodeVerifier(codeVerifier) { + storedCodeVerifier = codeVerifier; + }, + codeVerifier() { + return storedCodeVerifier; + }, + }; +} + +/** + * Build StreamableHTTPClientTransport options from an MCP server config's + * auth-related fields. + * + * Supports two strategies: + * 1. headers — static Authorization headers with ${VAR} env resolution. + * 2. oauth — OAuthClientProvider for servers that implement MCP OAuth. + * When both are provided, oauth takes precedence. + * + * Purpose: centralize HTTP auth config translation to avoid duplication + * across connection setup paths. + * + * Consumer: McpConnectionManager.getOrConnect() for HTTP transport setup. + */ +export function buildHttpTransportOpts(authConfig: AuthConfig): HttpTransportOptions { + const opts: HttpTransportOptions = {}; + if (authConfig.oauth) { + opts.authProvider = createCliOAuthProvider(authConfig.oauth); + return opts; + } + if (authConfig.headers && Object.keys(authConfig.headers).length > 0) { + opts.requestInit = { + headers: resolveHeaders(authConfig.headers), + }; + } + return opts; +} diff --git a/packages/coding-agent/src/core/mcp/config.ts b/packages/coding-agent/src/core/mcp/config.ts new file mode 100644 index 000000000..d0c4fb8f6 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/config.ts @@ -0,0 +1,105 @@ +/** + * config.ts — MCP server configuration types and readers. + * + * Purpose: centralize all MCP config resolution so connection-manager and + * the extension wrapper share one authoritative source of truth. + * + * Consumer: McpConnectionManager (getOrConnect), mcp_servers tool (list). + */ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface OAuthConfig { + clientId: string; + clientSecret?: string; + scopes?: string[]; + redirectUrl?: string; +} + +export interface McpServerConfig { + name: string; + transport: "stdio" | "http" | "unknown"; + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + url?: string; + headers?: Record; + oauth?: OAuthConfig; +} + +/** + * Read all MCP server configs from config files in priority order. + * First hit for a given server name wins (project-local overrides global). + * + * Purpose: provide the canonical ordered list of available MCP servers from + * all supported config locations. + * + * Consumer: McpConnectionManager.readConfigs(), mcp_servers tool. + */ +export function readMcpConfigs(): McpServerConfig[] { + const servers: McpServerConfig[] = []; + const seen = new Set(); + const sfHome = process.env["SF_HOME"] ?? join(homedir(), ".sf"); + const configPaths = [ + join(process.cwd(), ".mcp.json"), + join(process.cwd(), ".sf", "mcp.json"), + join(sfHome, "mcp.json"), + join(sfHome, "agent", "mcp.json"), + join(homedir(), ".mcp.json"), + ]; + for (const configPath of configPaths) { + try { + if (!existsSync(configPath)) continue; + const raw = readFileSync(configPath, "utf-8"); + const data = JSON.parse(raw) as Record; + const mcpServers = (data["mcpServers"] ?? data["servers"]) as Record | undefined; + if (!mcpServers || typeof mcpServers !== "object") continue; + for (const [name, config] of Object.entries(mcpServers)) { + if (seen.has(name)) continue; + seen.add(name); + const cfg = config as Record; + const hasCommand = typeof cfg["command"] === "string"; + const hasUrl = typeof cfg["url"] === "string"; + const transport: McpServerConfig["transport"] = hasCommand ? "stdio" : hasUrl ? "http" : "unknown"; + const hasHeaders = hasUrl && cfg["headers"] && typeof cfg["headers"] === "object"; + const hasOAuth = hasUrl && cfg["oauth"] && typeof cfg["oauth"] === "object"; + servers.push({ + name, + transport, + ...(hasCommand && { + command: cfg["command"] as string, + args: Array.isArray(cfg["args"]) ? (cfg["args"] as string[]) : undefined, + env: cfg["env"] && typeof cfg["env"] === "object" ? (cfg["env"] as Record) : undefined, + cwd: typeof cfg["cwd"] === "string" ? cfg["cwd"] : undefined, + }), + ...(hasUrl && { url: cfg["url"] as string }), + headers: hasHeaders ? (cfg["headers"] as Record) : undefined, + oauth: hasOAuth ? (cfg["oauth"] as OAuthConfig) : undefined, + }); + } + } catch { + // Non-fatal — config file may not exist or be malformed + } + } + return servers; +} + +/** + * Look up a single server config by name (case-insensitive fallback). + * + * Purpose: resolve a user-supplied server name to its canonical config entry, + * allowing minor casing differences without failing the lookup. + * + * Consumer: McpConnectionManager.getOrConnect(). + */ +export function getServerConfig( + name: string, + configs: McpServerConfig[], +): McpServerConfig | undefined { + const trimmed = name.trim(); + return configs.find( + (s) => s.name === trimmed || s.name.toLowerCase() === trimmed.toLowerCase(), + ); +} diff --git a/packages/coding-agent/src/core/mcp/connection-manager.ts b/packages/coding-agent/src/core/mcp/connection-manager.ts new file mode 100644 index 000000000..183fd2e61 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/connection-manager.ts @@ -0,0 +1,328 @@ +/** + * connection-manager.ts — MCP connection lifecycle and tool cache. + * + * Purpose: manage lazy MCP server connections with per-session lifetime, + * expose a typed connection cache, and coordinate tool discovery. + * + * Consumer: mcp-client extension tools (mcp_discover, mcp_call), + * lifecycle hooks (session_shutdown, session_switch). + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { buildHttpTransportOpts } from "./auth.js"; +import { getServerConfig, readMcpConfigs, type McpServerConfig } from "./config.js"; + +export type { McpServerConfig, OAuthConfig } from "./config.js"; +export type { AuthConfig, HttpTransportOptions } from "./auth.js"; + +export interface McpToolDefinition { + name: string; + description: string; + inputSchema?: unknown; +} + +export interface ConnectionStatus { + connected: boolean; + tools: string[]; + error?: string; +} + +export interface RegisterToolParams { + name: string; + label: string; + description: string; + inputSchemaRaw: unknown; + execute: ( + id: string, + params: Record, + signal?: AbortSignal, + ) => Promise<{ + content: { type: "text"; text: string }[]; + details: Record; + }>; +} + +export type RegisterToolFn = (params: RegisterToolParams) => void; + +const SAFE_CHILD_ENV_KEYS = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "LC_NUMERIC", + "LC_TIME", + "TMPDIR", + "TMP", + "TEMP", + "TZ", + "TERM", + "COLORTERM", +]); + +interface ActiveConnection { + client: Client; + transport: Transport; +} + +/** + * Manages MCP client connections for a single agent session. + * + * Each instance holds its own connection map, config cache, and tool cache. + * Call closeAll() / disconnectAll() on session shutdown or switch. + * + * Purpose: eliminate module-level mutable state so multiple concurrent + * sessions each get an isolated connection pool. + * + * Consumer: mcp-client extension (one instance per extension activation). + */ +export class McpConnectionManager { + private readonly connections = new Map(); + private configCache: McpServerConfig[] | null = null; + private readonly autoRegisteredServers = new Set(); + private readonly toolCache = new Map(); + + /** Read (and cache) the full ordered list of configured MCP servers. */ + readConfigs(): McpServerConfig[] { + if (this.configCache) return this.configCache; + this.configCache = readMcpConfigs(); + return this.configCache; + } + + /** Invalidate the config cache so the next readConfigs() re-reads from disk. */ + invalidateConfigCache(): void { + this.configCache = null; + } + + /** Return the config for a single server by name (case-insensitive fallback). */ + getServerConfig(name: string): McpServerConfig | undefined { + return getServerConfig(name, this.readConfigs()); + } + + /** + * Return true if the given server name is currently connected. + * + * Purpose: let the mcp_servers tool show live connection status without + * triggering a connection. + * + * Consumer: formatServerList in the extension wrapper. + */ + isConnected(name: string): boolean { + return this.connections.has(name); + } + + /** Return cached tools for a server, or undefined if not yet discovered. */ + getCachedTools(serverName: string): McpToolDefinition[] | undefined { + return this.toolCache.get(serverName); + } + + /** Store discovered tools in the cache. */ + setCachedTools(serverName: string, tools: McpToolDefinition[]): void { + this.toolCache.set(serverName, tools); + } + + /** + * Return a live MCP Client for the named server, connecting lazily on first call. + * + * Purpose: provide a single canonical connect path so every tool (discover, + * call, auto-registered) always gets the same cached client. + * + * Consumer: mcp_discover, mcp_call, registerToolsForServer execute functions. + */ + async getOrConnect(name: string, signal?: AbortSignal): Promise { + const config = this.getServerConfig(name); + if (!config) { + throw new Error( + `Unknown MCP server: "${name}". Use mcp_servers to list available servers.`, + ); + } + const existing = this.connections.get(config.name); + if (existing) return existing.client; + + const client = new Client({ name: "sf", version: "1.0.0" }); + let transport: Transport; + + if (config.transport === "stdio" && config.command) { + transport = new StdioClientTransport({ + command: config.command, + args: config.args, + env: this.buildChildEnv(config.env), + cwd: config.cwd, + stderr: "pipe", + }); + } else if (config.transport === "http" && config.url) { + const resolvedUrl = config.url.replace( + /\$\{([^}]+)\}/g, + (_: string, varName: string) => process.env[varName] ?? "", + ); + const httpOpts = buildHttpTransportOpts({ + headers: config.headers, + oauth: config.oauth, + }); + transport = new StreamableHTTPClientTransport( + new URL(resolvedUrl), + httpOpts, + ); + } else { + throw new Error( + `Server "${config.name}" has unsupported transport: ${config.transport}`, + ); + } + + try { + await client.connect(transport, { signal, timeout: 30000 }); + } catch (err) { + try { await transport.close(); } catch { /* best-effort */ } + try { await client.close(); } catch { /* best-effort */ } + throw err; + } + + this.connections.set(config.name, { client, transport }); + return client; + } + + /** + * Close all active connections and clear the tool cache. + * + * Purpose: ensure clean shutdown on session end so no dangling stdio child + * processes or HTTP keep-alive connections survive the session. + * + * Consumer: session_shutdown and session_switch lifecycle hooks. + */ + async closeAll(): Promise { + const closing = Array.from(this.connections.entries()).map( + async ([name, conn]) => { + try { await conn.transport.close(); } catch { /* best-effort */ } + try { await conn.client.close(); } catch { /* best-effort */ } + this.connections.delete(name); + }, + ); + await Promise.allSettled(closing); + this.toolCache.clear(); + this.autoRegisteredServers.clear(); + } + + /** + * Alias for closeAll — named for the /mcp reload command surface. + * + * Purpose: allow reload command to disconnect all servers so the next + * mcp_discover or mcp_call lazily reconnects with fresh config. + * + * Consumer: /mcp reload command handler. + */ + async disconnectAll(): Promise { + await this.closeAll(); + } + + /** + * Return the live connection status for a named server. + * Safe to call even when the server has never been connected. + * + * Purpose: provide non-destructive status inspection for the status command. + * + * Consumer: /mcp status command handler. + */ + getConnectionStatus(name: string): ConnectionStatus { + const conn = this.connections.get(name); + const cached = this.toolCache.get(name); + return { + connected: !!conn, + tools: cached ? cached.map((t) => t.name) : [], + error: undefined, + }; + } + + /** + * Register MCP tools discovered for a server as first-class agent tools. + * No-op if the server's tools were already registered in this session. + * + * Purpose: surface each MCP tool by its real name so the LLM can call + * tools directly (e.g. serena_find_symbol) without the mcp_call indirection. + * + * Consumer: mcp_discover execute handler, after listTools() succeeds. + * + * @param serverName Canonical server name from the config. + * @param tools Tool list returned by client.listTools(). + * @param registerTool Extension-provided callback that registers one tool. + */ + registerToolsForServer( + serverName: string, + tools: McpToolDefinition[], + registerTool: RegisterToolFn, + ): void { + if (this.autoRegisteredServers.has(serverName)) return; + this.autoRegisteredServers.add(serverName); + for (const tool of tools) { + const piToolName = `${serverName}_${tool.name}`; + const description = tool.description || `MCP tool: ${tool.name} on ${serverName}`; + try { + registerTool({ + name: piToolName, + label: `${serverName}:${tool.name}`, + description, + inputSchemaRaw: tool.inputSchema, + execute: async (_id, params, signal) => { + const client = await this.getOrConnect(serverName, signal); + const result = await client.callTool( + { name: tool.name, arguments: params }, + undefined, + { signal, timeout: 60000 }, + ); + const contentItems = result.content as { type: string; text?: string }[]; + const raw = contentItems + .map((c) => (c.type === "text" ? (c.text ?? "") : JSON.stringify(c))) + .join("\n"); + return { + content: [{ type: "text" as const, text: raw }], + details: { server: serverName, tool: tool.name }, + }; + }, + }); + } catch { + // Non-fatal — tool registration can fail if schema is unconvertible + } + } + } + + /** + * Build a sanitised child environment for stdio transport processes. + * + * Purpose: prevent leaking host secrets or unwanted env vars into MCP + * server child processes by allow-listing safe keys and merging config- + * provided env on top. + * + * Consumer: getOrConnect() when creating a StdioClientTransport. + */ + buildChildEnv(configEnv?: Record): Record { + const safe: Record = {}; + for (const key of SAFE_CHILD_ENV_KEYS) { + const val = process.env[key]; + if (val !== undefined) safe[key] = val; + } + return { ...safe, ...this.resolveEnv(configEnv ?? {}) }; + } + + private resolveEnv(env: Record): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") { + resolved[key] = value.replace( + /\$\{([^}]+)\}/g, + (_match: string, varName: string) => process.env[varName] ?? "", + ); + } else { + resolved[key] = value; + } + } + return resolved; + } +} + +/** Export SAFE_CHILD_ENV_KEYS for tests that need to verify env filtering. */ +export { SAFE_CHILD_ENV_KEYS }; diff --git a/packages/coding-agent/src/core/mcp/index.ts b/packages/coding-agent/src/core/mcp/index.ts new file mode 100644 index 000000000..beb36a08a --- /dev/null +++ b/packages/coding-agent/src/core/mcp/index.ts @@ -0,0 +1,20 @@ +/** + * index.ts — MCP core module public API. + * + * Purpose: single re-export point for all MCP types and classes so the + * rest of the coding-agent package and extensions use a stable import path. + * + * Consumer: packages/coding-agent/src/index.ts, mcp-client extension. + */ +export { + McpConnectionManager, + SAFE_CHILD_ENV_KEYS, + type ConnectionStatus, + type McpServerConfig, + type McpToolDefinition, + type OAuthConfig, + type RegisterToolFn, + type RegisterToolParams, +} from "./connection-manager.js"; +export { buildHttpTransportOpts, createCliOAuthProvider, type AuthConfig, type HttpTransportOptions } from "./auth.js"; +export { readMcpConfigs, getServerConfig } from "./config.js"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 32f3c9f68..ebfb62496 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -434,7 +434,29 @@ export { attachJsonlLineReader, serializeJsonLine } from "./modes/rpc/jsonl.js"; // Clipboard utilities export { copyToClipboard } from "./utils/clipboard.js"; export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.js"; +// Pure formatting utilities (duration, token counts, sparklines, ANSI, etc.) +export { + fileLink, + formatDateShort, + formatDuration, + formatTokenCount, + type NormalizeStringArrayOptions, + normalizeStringArray, + sparkline, + stripAnsi, + truncateWithEllipsis, +} from "./utils/format.js"; // Cross-platform path display export { toPosixPath } from "./utils/path-display.js"; // Shell utilities export { getShellConfig, sanitizeCommand } from "./utils/shell.js"; +// MCP connection manager +export { + McpConnectionManager, + buildHttpTransportOpts, + type McpServerConfig, + type ConnectionStatus, + type McpToolDefinition, + type RegisterToolFn, + type RegisterToolParams, +} from "./core/mcp/index.js"; diff --git a/src/resources/extensions/mcp-client/auth.js b/src/resources/extensions/mcp-client/auth.js index d90c75765..30463fb75 100644 --- a/src/resources/extensions/mcp-client/auth.js +++ b/src/resources/extensions/mcp-client/auth.js @@ -1,106 +1,8 @@ /** - * MCP Client OAuth / Auth helpers + * auth.js — re-exports MCP auth helpers from @singularity-forge/coding-agent. * - * Builds transport options (headers, OAuthClientProvider) from MCP server - * config entries so that HTTP transports can authenticate with remote - * servers (Sentry, Linear, etc.). - * - * Fixes #2160 — MCP HTTP transport lacked an OAuth auth provider. + * The implementation now lives in packages/coding-agent/src/core/mcp/auth.ts. + * This shim keeps backward compatibility for any import of ./auth.js from + * within the extension or from tests. */ -// ─── Env resolution ─────────────────────────────────────────────────────────── -/** Resolve `${VAR}` references in a string against `process.env`. */ -function resolveEnvValue(value) { - return value.replace( - /\$\{([^}]+)\}/g, - (_match, varName) => process.env[varName] ?? "", - ); -} -function resolveHeaders(raw) { - const resolved = {}; - for (const [key, value] of Object.entries(raw)) { - resolved[key] = typeof value === "string" ? resolveEnvValue(value) : value; - } - return resolved; -} -// ─── OAuth provider (minimal CLI-friendly implementation) ───────────────────── -/** - * Creates a minimal `OAuthClientProvider` suitable for CLI / headless use. - * - * This provider supports: - * - Pre-configured client credentials (client_id, optional client_secret) - * - Token storage in memory (per-session) - * - Scopes - * - * For full interactive OAuth flows (browser redirect), a richer provider would - * be needed, but for server-to-server and pre-authed scenarios this is - * sufficient. - */ -function createCliOAuthProvider(config) { - let storedTokens; - let storedCodeVerifier = ""; - return { - get redirectUrl() { - return config.redirectUrl ?? "http://localhost:0/callback"; - }, - get clientMetadata() { - return { - redirect_uris: [config.redirectUrl ?? "http://localhost:0/callback"], - client_name: "sf", - ...(config.scopes ? { scope: config.scopes.join(" ") } : {}), - }; - }, - clientInformation() { - return { - client_id: config.clientId, - ...(config.clientSecret ? { client_secret: config.clientSecret } : {}), - }; - }, - tokens() { - return storedTokens; - }, - saveTokens(tokens) { - storedTokens = tokens; - }, - redirectToAuthorization(authorizationUrl) { - // In a CLI context we can't open a browser automatically. - // Log the URL so the user can manually visit it. - // eslint-disable-next-line no-console - console.error( - `[MCP OAuth] Authorization required. Visit:\n ${authorizationUrl.toString()}`, - ); - }, - saveCodeVerifier(codeVerifier) { - storedCodeVerifier = codeVerifier; - }, - codeVerifier() { - return storedCodeVerifier; - }, - }; -} -// ─── Public API ─────────────────────────────────────────────────────────────── -/** - * Build `StreamableHTTPClientTransportOptions` from an MCP server config's - * auth-related fields. - * - * Supports two auth strategies: - * 1. **`headers`** — static Authorization (or other) headers, with `${VAR}` env resolution. - * 2. **`oauth`** — full OAuthClientProvider for servers that implement MCP OAuth. - * - * When both are provided, `oauth` takes precedence (the SDK's built-in OAuth - * flow handles token refresh automatically). - */ -export function buildHttpTransportOpts(authConfig) { - const opts = {}; - // OAuth takes precedence - if (authConfig.oauth) { - opts.authProvider = createCliOAuthProvider(authConfig.oauth); - return opts; - } - // Static headers (with env var resolution) - if (authConfig.headers && Object.keys(authConfig.headers).length > 0) { - opts.requestInit = { - headers: resolveHeaders(authConfig.headers), - }; - } - return opts; -} +export { buildHttpTransportOpts, createCliOAuthProvider } from "@singularity-forge/coding-agent"; diff --git a/src/resources/extensions/mcp-client/index.js b/src/resources/extensions/mcp-client/index.js index b5af83f54..28e7b9ca3 100644 --- a/src/resources/extensions/mcp-client/index.js +++ b/src/resources/extensions/mcp-client/index.js @@ -9,312 +9,94 @@ * mcp_servers — List available MCP servers from config files * mcp_discover — Get tool signatures for a specific server (lazy connect) * mcp_call — Call a tool on an MCP server (lazy connect) + * + * Connection logic lives in packages/coding-agent/src/core/mcp/. + * This file is the thin extension wrapper: tool definitions + lifecycle hooks. */ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { Client } from "@modelcontextprotocol/sdk/client"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { Type } from "@sinclair/typebox"; import { - DEFAULT_MAX_BYTES, - DEFAULT_MAX_LINES, - formatSize, - truncateHead, +DEFAULT_MAX_BYTES, +DEFAULT_MAX_LINES, +McpConnectionManager, +formatSize, +truncateHead, } from "@singularity-forge/coding-agent"; import { Text } from "@singularity-forge/tui"; -import { buildHttpTransportOpts } from "./auth.js"; -// ─── Connection Manager ─────────────────────────────────────────────────────── -const connections = new Map(); -let configCache = null; -/** Servers whose MCP tools have been auto-registered as first-class pi tools. */ -const autoRegisteredServers = new Set(); -const toolCache = new Map(); -function readConfigs() { - if (configCache) return configCache; - const servers = []; - const seen = new Set(); - // Search order matters: first hit wins (seen-guard below), so put - // project-local configs first — a project can override or shadow a - // globally-registered server by re-declaring the same name. - const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); - const configPaths = [ - join(process.cwd(), ".mcp.json"), - join(process.cwd(), ".sf", "mcp.json"), - join(sfHome, "mcp.json"), // global: ~/.sf/mcp.json - join(sfHome, "agent", "mcp.json"), // global: ~/.sf/agent/mcp.json (legacy alt) - join(homedir(), ".mcp.json"), // user-global: ~/.mcp.json (Claude Code, npx, etc.) - ]; - for (const configPath of configPaths) { - try { - if (!existsSync(configPath)) continue; - const raw = readFileSync(configPath, "utf-8"); - const data = JSON.parse(raw); - const mcpServers = data.mcpServers ?? data.servers; - if (!mcpServers || typeof mcpServers !== "object") continue; - for (const [name, config] of Object.entries(mcpServers)) { - if (seen.has(name)) continue; - seen.add(name); - const hasCommand = typeof config.command === "string"; - const hasUrl = typeof config.url === "string"; - const transport = hasCommand ? "stdio" : hasUrl ? "http" : "unknown"; - const hasHeaders = - hasUrl && config.headers && typeof config.headers === "object"; - const hasOAuth = - hasUrl && config.oauth && typeof config.oauth === "object"; - servers.push({ - name, - transport, - ...(hasCommand && { - command: config.command, - args: Array.isArray(config.args) ? config.args : undefined, - env: - config.env && typeof config.env === "object" - ? config.env - : undefined, - cwd: typeof config.cwd === "string" ? config.cwd : undefined, - }), - ...(hasUrl && { url: config.url }), - headers: hasHeaders ? config.headers : undefined, - oauth: hasOAuth ? config.oauth : undefined, - }); - } - } catch { - // Non-fatal — config file may not exist or be malformed - } - } - configCache = servers; - return servers; -} -function getServerConfig(name) { - const trimmed = name.trim(); - return readConfigs().find( - (s) => s.name === trimmed || s.name.toLowerCase() === trimmed.toLowerCase(), - ); -} -const SAFE_CHILD_ENV_KEYS = new Set([ - "PATH", - "HOME", - "USER", - "LOGNAME", - "SHELL", - "LANG", - "LC_ALL", - "LC_CTYPE", - "LC_MESSAGES", - "LC_NUMERIC", - "LC_TIME", - "TMPDIR", - "TMP", - "TEMP", - "TZ", - "TERM", - "COLORTERM", -]); -function buildChildEnv(configEnv) { - const safe = {}; - for (const key of SAFE_CHILD_ENV_KEYS) { - if (process.env[key] !== undefined) safe[key] = process.env[key]; - } - return { ...safe, ...resolveEnv(configEnv ?? {}) }; -} -/** Resolve ${VAR} references in env values against process.env. */ -function resolveEnv(env) { - const resolved = {}; - for (const [key, value] of Object.entries(env)) { - if (typeof value === "string") { - resolved[key] = value.replace( - /\$\{([^}]+)\}/g, - (_match, varName) => process.env[varName] ?? "", - ); - } else { - resolved[key] = value; - } - } - return resolved; -} +// ─── Module-level manager (session-scoped) ──────────────────────────────────── +const manager = new McpConnectionManager(); + // ─── JSON Schema → TypeBox converter ───────────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any function jsonSchemaPropToTypeBox(schema) { - if (!schema || typeof schema !== "object") return Type.Any(); - const t = schema.type; - if (t === "string") return Type.String({ description: schema.description }); - if (t === "number" || t === "integer") - return Type.Number({ description: schema.description }); - if (t === "boolean") return Type.Boolean({ description: schema.description }); - if (t === "array") return Type.Array(Type.Any()); - if (t === "object") { - const props = schema.properties; - if (props) { - const entries = {}; - for (const [k, v] of Object.entries(props)) { - entries[k] = jsonSchemaPropToTypeBox(v); - } - return Type.Object(entries); - } - } - return Type.Any(); +if (!schema || typeof schema !== "object") return Type.Any(); +const t = schema.type; +if (t === "string") return Type.String({ description: schema.description }); +if (t === "number" || t === "integer") +return Type.Number({ description: schema.description }); +if (t === "boolean") return Type.Boolean({ description: schema.description }); +if (t === "array") return Type.Array(Type.Any()); +if (t === "object") { +const props = schema.properties; +if (props) { +const entries = {}; +for (const [k, v] of Object.entries(props)) { +entries[k] = jsonSchemaPropToTypeBox(v); +} +return Type.Object(entries); +} +} +return Type.Any(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function jsonSchemaToTypeBox(schema) { - if (!schema || typeof schema !== "object") return Type.Object({}); - const obj = schema; - const props = obj.properties; - if (!props) return Type.Object({}); - const entries = {}; - for (const [k, v] of Object.entries(props)) { - entries[k] = jsonSchemaPropToTypeBox(v); - } - return Type.Object(entries); +if (!schema || typeof schema !== "object") return Type.Object({}); +const obj = schema; +const props = obj.properties; +if (!props) return Type.Object({}); +const entries = {}; +for (const [k, v] of Object.entries(props)) { +entries[k] = jsonSchemaPropToTypeBox(v); } -// ─── Dynamic MCP tool auto-registration ─────────────────────────────────────── -function registerMcpToolsForServer(pi, serverName, tools) { - if (autoRegisteredServers.has(serverName)) return; - autoRegisteredServers.add(serverName); - for (const tool of tools) { - const piToolName = `${serverName}_${tool.name}`; - const description = - tool.description || `MCP tool: ${tool.name} on ${serverName}`; - // Build parameter TypeBox type from MCP inputSchema - const paramType = tool.inputSchema - ? jsonSchemaToTypeBox(tool.inputSchema) - : Type.Object({}); - try { - pi.registerTool({ - name: piToolName, - label: `${serverName}:${tool.name}`, - description, - parameters: paramType, - async execute(_id, params) { - // Delegate to the internal mcp_call logic directly via the client - const client = await getOrConnect(serverName); - const result = await client.callTool( - { name: tool.name, arguments: params }, - undefined, - { timeout: 60000 }, - ); - const contentItems = result.content; - const raw = contentItems - .map((c) => - c.type === "text" ? (c.text ?? "") : JSON.stringify(c), - ) - .join("\n"); - const truncation = truncateHead(raw, { - maxLines: DEFAULT_MAX_LINES, - maxBytes: DEFAULT_MAX_BYTES, - }); - let finalText = truncation.content; - if (truncation.truncated) { - finalText += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines]`; - } - return { - content: [{ type: "text", text: finalText }], - details: { server: serverName, tool: tool.name }, - }; - }, - }); - } catch { - // Non-fatal — tool registration can fail if schema is unconvertible - } - } -} -async function getOrConnect(name, signal) { - const config = getServerConfig(name); - if (!config) - throw new Error( - `Unknown MCP server: "${name}". Use mcp_servers to list available servers.`, - ); - // Always use config.name as the canonical cache key so that variant - // casing / whitespace still hits the same connection. - const existing = connections.get(config.name); - if (existing) return existing.client; - const client = new Client({ name: "sf", version: "1.0.0" }); - let transport; - if (config.transport === "stdio" && config.command) { - transport = new StdioClientTransport({ - command: config.command, - args: config.args, - env: buildChildEnv(config.env), - cwd: config.cwd, - stderr: "pipe", - }); - } else if (config.transport === "http" && config.url) { - const resolvedUrl = config.url.replace( - /\$\{([^}]+)\}/g, - (_, varName) => process.env[varName] ?? "", - ); - const httpOpts = buildHttpTransportOpts({ - headers: config.headers, - oauth: config.oauth, - }); - transport = new StreamableHTTPClientTransport( - new URL(resolvedUrl), - httpOpts, - ); - } else { - throw new Error( - `Server "${config.name}" has unsupported transport: ${config.transport}`, - ); - } - try { - await client.connect(transport, { signal, timeout: 30000 }); - } catch (err) { - try { await transport.close(); } catch { /* best-effort */ } - try { await client.close(); } catch { /* best-effort */ } - throw err; - } - connections.set(config.name, { client, transport }); - return client; -} -async function closeAll() { - const closing = Array.from(connections.entries()).map( - async ([name, conn]) => { - try { await conn.transport.close(); } catch { /* best-effort */ } - try { await conn.client.close(); } catch { /* best-effort */ } - connections.delete(name); - }, - ); - await Promise.allSettled(closing); - toolCache.clear(); - autoRegisteredServers.clear(); +return Type.Object(entries); } + // ─── Formatters ─────────────────────────────────────────────────────────────── function formatServerList(servers) { - if (servers.length === 0) - return "No MCP servers configured. Add servers to .mcp.json or .sf/mcp.json."; - const lines = [`${servers.length} MCP servers configured:\n`]; - for (const s of servers) { - const connected = connections.has(s.name) ? "✓" : "○"; - const cached = toolCache.get(s.name); - const toolCount = cached ? ` — ${cached.length} tools` : ""; - lines.push(`${connected} ${s.name} (${s.transport})${toolCount}`); - } - lines.push( - "\nUse mcp_discover to see full tool schemas for a specific server.", - ); - lines.push("Use mcp_call to invoke a tool: mcp_call(server, tool, args)."); - return lines.join("\n"); +if (servers.length === 0) +return "No MCP servers configured. Add servers to .mcp.json or .sf/mcp.json."; +const lines = [`${servers.length} MCP servers configured:\n`]; +for (const s of servers) { +const connected = manager.isConnected(s.name) ? "✓" : "○"; +const cached = manager.getCachedTools(s.name); +const toolCount = cached ? ` — ${cached.length} tools` : ""; +lines.push(`${connected} ${s.name} (${s.transport})${toolCount}`); +} +lines.push( +"\nUse mcp_discover to see full tool schemas for a specific server.", +); +lines.push("Use mcp_call to invoke a tool: mcp_call(server, tool, args)."); +return lines.join("\n"); } function formatToolList(serverName, tools) { - const lines = [`${serverName} — ${tools.length} tools:\n`]; - for (const tool of tools) { - lines.push(`## ${tool.name}`); - if (tool.description) lines.push(tool.description); - if (tool.inputSchema) { - lines.push("```json"); - lines.push(JSON.stringify(tool.inputSchema, null, 2)); - lines.push("```"); - } - lines.push(""); - } - lines.push( - `Call with: mcp_call(server="${serverName}", tool="", args={...})`, - ); - return lines.join("\n"); +const lines = [`${serverName} — ${tools.length} tools:\n`]; +for (const tool of tools) { +lines.push(`## ${tool.name}`); +if (tool.description) lines.push(tool.description); +if (tool.inputSchema) { +lines.push("```json"); +lines.push(JSON.stringify(tool.inputSchema, null, 2)); +lines.push("```"); } -// ─── Status helper (consumed by /sf mcp) ───────────────────────────────────── +lines.push(""); +} +lines.push( +`Call with: mcp_call(server="${serverName}", tool="", args={...})`, +); +return lines.join("\n"); +} + +// ─── Status helpers (consumed by /sf mcp) ──────────────────────────────────── /** * Disconnect all active MCP connections and clear the tool cache. * Servers will lazily reconnect on the next mcp_discover or mcp_call. @@ -323,7 +105,7 @@ function formatToolList(serverName, tools) { * Consumer: /mcp reload command handler in commands-mcp-status.js. */ export async function disconnectAll() { - await closeAll(); +await manager.disconnectAll(); } /** @@ -331,291 +113,311 @@ export async function disconnectAll() { * Safe to call even when the server has never been connected. */ export function getConnectionStatus(name) { - const conn = connections.get(name); - const cached = toolCache.get(name); - return { - connected: !!conn, - tools: cached ? cached.map((t) => t.name) : [], - error: undefined, - }; +return manager.getConnectionStatus(name); } + // ─── Test-exported helpers ──────────────────────────────────────────────────── export function _buildMcpChildEnvForTest(env) { - return buildChildEnv(env); +return manager.buildChildEnv(env); } export function _buildMcpTrustConfirmOptionsForTest(signal) { - return { timeout: 120_000, signal }; +return { timeout: 120_000, signal }; } + // ─── Extension ──────────────────────────────────────────────────────────────── export default function (pi) { - // ── mcp_servers ────────────────────────────────────────────────────────── - pi.registerTool({ - name: "mcp_servers", - label: "MCP Servers", - description: - "List all available MCP servers configured in project files (.mcp.json, .sf/mcp.json). " + - "Shows server names, transport type, and connection status. After mcp_discover, each server's " + - "tools are auto-registered as first-class pi tools (e.g. serena_find_symbol).", - promptSnippet: "List available MCP servers from project configuration", - promptGuidelines: [ - "Call mcp_servers to see what MCP servers are available before trying to use one.", - "After mcp_discover(server), the server's tools appear as real pi tools.", - "MCP servers provide external integrations (Twitter, Linear, Railway, etc.) via the Model Context Protocol.", - "After listing, use mcp_discover(server) to get tool schemas, then mcp_call(server, tool, args) to invoke.", - ], - parameters: Type.Object({ - refresh: Type.Optional( - Type.Boolean({ - description: "Force refresh the server list (default: use cache)", - }), - ), - }), - async execute(_id, params) { - if (params.refresh) configCache = null; - const servers = readConfigs(); - return { - content: [{ type: "text", text: formatServerList(servers) }], - details: { - serverCount: servers.length, - cached: !params.refresh && configCache !== null, - }, - }; - }, - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("mcp_servers")); - if (args.refresh) text += theme.fg("warning", " (refresh)"); - return new Text(text, 0, 0); - }, - renderResult(result, { isPartial }, theme) { - if (isPartial) - return new Text(theme.fg("warning", "Reading MCP config..."), 0, 0); - const d = result.details; - return new Text( - theme.fg("success", `${d?.serverCount ?? 0} servers configured`), - 0, - 0, - ); - }, - }); - // ── mcp_discover ───────────────────────────────────────────────────────── - pi.registerTool({ - name: "mcp_discover", - label: "MCP Discover", - description: - "Get detailed tool signatures and JSON schemas for a specific MCP server. " + - "Connects to the server on first call (lazy connection). " + - "After discovery, each MCP tool is auto-registered as a first-class pi tool " + - "(e.g. serena_find_symbol) — the LLM can call them directly without mcp_call.", - promptSnippet: - "Discover MCP server tools and register them as first-class pi tools", - promptGuidelines: [ - "Call mcp_discover(server) to connect to an MCP server and surface its tools.", - "After discovery, the LLM sees each tool by its real name (e.g. serena_search_for_pattern).", - "Call tools directly by their names instead of going through mcp_call.", - ], - parameters: Type.Object({ - server: Type.String({ - description: - "MCP server name (from mcp_servers output), e.g. 'railway', 'twitter-mcp', 'linear'", - }), - }), - async execute(_id, params, signal) { - try { - // Return cached tools if available - const cached = toolCache.get(params.server); - if (cached) { - const text = formatToolList(params.server, cached); - const truncation = truncateHead(text, { - maxLines: DEFAULT_MAX_LINES, - maxBytes: DEFAULT_MAX_BYTES, - }); - let finalText = truncation.content; - if (truncation.truncated) { - finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; - } - return { - content: [{ type: "text", text: finalText }], - details: { - server: params.server, - toolCount: cached.length, - cached: true, - }, - }; - } - const client = await getOrConnect(params.server, signal); - const result = await client.listTools(undefined, { - signal, - timeout: 30000, - }); - const tools = (result.tools ?? []).map((t) => ({ - name: t.name, - description: t.description ?? "", - inputSchema: t.inputSchema, - })); - toolCache.set(params.server, tools); - // Auto-register each MCP tool as a first-class pi tool. - // After this, the LLM sees e.g. serena_find_symbol directly instead - // of going through the generic mcp_call indirection. - registerMcpToolsForServer(pi, params.server, tools); - const text = formatToolList(params.server, tools); - const truncation = truncateHead(text, { - maxLines: DEFAULT_MAX_LINES, - maxBytes: DEFAULT_MAX_BYTES, - }); - let finalText = truncation.content; - if (truncation.truncated) { - finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; - } - return { - content: [{ type: "text", text: finalText }], - details: { - server: params.server, - toolCount: tools.length, - cached: false, - }, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to discover tools for "${params.server}": ${msg}`, - ); - } - }, - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("mcp_discover ")); - text += theme.fg("accent", args.server); - return new Text(text, 0, 0); - }, - renderResult(result, { isPartial }, theme) { - if (isPartial) - return new Text(theme.fg("warning", "Discovering tools..."), 0, 0); - const d = result.details; - return new Text( - theme.fg("success", `${d?.toolCount ?? 0} tools`) + - theme.fg("dim", ` · ${d?.server}`), - 0, - 0, - ); - }, - }); - // ── mcp_call ───────────────────────────────────────────────────────────── - pi.registerTool({ - name: "mcp_call", - label: "MCP Call", - description: - "Call a tool on an MCP server. Provide the server name, tool name, and arguments. " + - "Connects to the server on first call (lazy connection). " + - "Use mcp_discover first to see available tools and their required arguments.", - promptSnippet: "Call a tool on an MCP server", - promptGuidelines: [ - "Always use mcp_discover first to understand the tool's parameters before calling mcp_call.", - "Arguments are passed as a JSON object matching the tool's input schema.", - ], - parameters: Type.Object({ - server: Type.String({ - description: "MCP server name, e.g. 'railway', 'twitter-mcp'", - }), - tool: Type.String({ - description: "Tool name on that server, e.g. 'railway_list_projects'", - }), - args: Type.Optional( - Type.Object( - {}, - { - additionalProperties: true, - description: - "Tool arguments as key-value pairs matching the tool's input schema", - }, - ), - ), - }), - async execute(_id, params, signal) { - try { - const client = await getOrConnect(params.server, signal); - const result = await client.callTool( - { name: params.tool, arguments: params.args ?? {} }, - undefined, - { signal, timeout: 60000 }, - ); - // Serialize result content to text - const contentItems = result.content; - const raw = contentItems - .map((c) => (c.type === "text" ? (c.text ?? "") : JSON.stringify(c))) - .join("\n"); - const truncation = truncateHead(raw, { - maxLines: DEFAULT_MAX_LINES, - maxBytes: DEFAULT_MAX_BYTES, - }); - let finalText = truncation.content; - if (truncation.truncated) { - finalText += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; - } - return { - content: [{ type: "text", text: finalText }], - details: { - server: params.server, - tool: params.tool, - charCount: finalText.length, - truncated: truncation.truncated, - }, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error( - `MCP call failed: ${params.server}.${params.tool}\n${msg}`, - ); - } - }, - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("mcp_call ")); - text += theme.fg("accent", `${args.server}.${args.tool}`); - if (args.args && Object.keys(args.args).length > 0) { - const preview = Object.entries(args.args) - .slice(0, 3) - .map(([k, v]) => { - const val = typeof v === "string" ? v : JSON.stringify(v); - return `${k}:${val.length > 30 ? val.slice(0, 30) + "…" : val}`; - }) - .join(" "); - text += " " + theme.fg("muted", preview); - } - return new Text(text, 0, 0); - }, - renderResult(result, { isPartial, expanded }, theme) { - if (isPartial) - return new Text(theme.fg("warning", "Calling MCP tool..."), 0, 0); - const d = result.details; - let text = theme.fg("success", `✓ ${d?.server}.${d?.tool}`); - text += theme.fg( - "dim", - ` · ${(d?.charCount ?? 0).toLocaleString()} chars`, - ); - if (d?.truncated) text += theme.fg("warning", " · truncated"); - if (expanded) { - const content = result.content[0]; - if (content?.type === "text") { - const preview = content.text.split("\n").slice(0, 15).join("\n"); - text += "\n\n" + theme.fg("dim", preview); - } - } - return new Text(text, 0, 0); - }, - }); - // ── Lifecycle ───────────────────────────────────────────────────────────── - pi.on("session_start", async (_event, ctx) => { - const servers = readConfigs(); - if (servers.length > 0) { - ctx.ui.notify( - `MCP client ready — ${servers.length} server(s) configured`, - "info", - ); - } - }); - pi.on("session_shutdown", async () => { - await closeAll(); - }); - pi.on("session_switch", async () => { - await closeAll(); - configCache = null; - }); +// ── mcp_servers ────────────────────────────────────────────────────────── +pi.registerTool({ +name: "mcp_servers", +label: "MCP Servers", +description: +"List all available MCP servers configured in project files (.mcp.json, .sf/mcp.json). " + +"Shows server names, transport type, and connection status. After mcp_discover, each server's " + +"tools are auto-registered as first-class pi tools (e.g. serena_find_symbol).", +promptSnippet: "List available MCP servers from project configuration", +promptGuidelines: [ +"Call mcp_servers to see what MCP servers are available before trying to use one.", +"After mcp_discover(server), the server's tools appear as real pi tools.", +"MCP servers provide external integrations (Twitter, Linear, Railway, etc.) via the Model Context Protocol.", +"After listing, use mcp_discover(server) to get tool schemas, then mcp_call(server, tool, args) to invoke.", +], +parameters: Type.Object({ +refresh: Type.Optional( +Type.Boolean({ +description: "Force refresh the server list (default: use cache)", +}), +), +}), +async execute(_id, params) { +if (params.refresh) manager.invalidateConfigCache(); +const servers = manager.readConfigs(); +return { +content: [{ type: "text", text: formatServerList(servers) }], +details: { +serverCount: servers.length, +cached: !params.refresh, +}, +}; +}, +renderCall(args, theme) { +let text = theme.fg("toolTitle", theme.bold("mcp_servers")); +if (args.refresh) text += theme.fg("warning", " (refresh)"); +return new Text(text, 0, 0); +}, +renderResult(result, { isPartial }, theme) { +if (isPartial) +return new Text(theme.fg("warning", "Reading MCP config..."), 0, 0); +const d = result.details; +return new Text( +theme.fg("success", `${d?.serverCount ?? 0} servers configured`), +0, +0, +); +}, +}); + +// ── mcp_discover ───────────────────────────────────────────────────────── +pi.registerTool({ +name: "mcp_discover", +label: "MCP Discover", +description: +"Get detailed tool signatures and JSON schemas for a specific MCP server. " + +"Connects to the server on first call (lazy connection). " + +"After discovery, each MCP tool is auto-registered as a first-class pi tool " + +"(e.g. serena_find_symbol) — the LLM can call them directly without mcp_call.", +promptSnippet: +"Discover MCP server tools and register them as first-class pi tools", +promptGuidelines: [ +"Call mcp_discover(server) to connect to an MCP server and surface its tools.", +"After discovery, the LLM sees each tool by its real name (e.g. serena_search_for_pattern).", +"Call tools directly by their names instead of going through mcp_call.", +], +parameters: Type.Object({ +server: Type.String({ +description: +"MCP server name (from mcp_servers output), e.g. 'railway', 'twitter-mcp', 'linear'", +}), +}), +async execute(_id, params, signal) { +try { +const cached = manager.getCachedTools(params.server); +if (cached) { +const text = formatToolList(params.server, cached); +const truncation = truncateHead(text, { +maxLines: DEFAULT_MAX_LINES, +maxBytes: DEFAULT_MAX_BYTES, +}); +let finalText = truncation.content; +if (truncation.truncated) { +finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; +} +return { +content: [{ type: "text", text: finalText }], +details: { +server: params.server, +toolCount: cached.length, +cached: true, +}, +}; +} +const client = await manager.getOrConnect(params.server, signal); +const result = await client.listTools(undefined, { +signal, +timeout: 30000, +}); +const tools = (result.tools ?? []).map((t) => ({ +name: t.name, +description: t.description ?? "", +inputSchema: t.inputSchema, +})); +manager.setCachedTools(params.server, tools); +// Auto-register each MCP tool as a first-class pi tool. +manager.registerToolsForServer(params.server, tools, ({ name, label, description, inputSchemaRaw, execute }) => { +const paramType = inputSchemaRaw +? jsonSchemaToTypeBox(inputSchemaRaw) +: Type.Object({}); +pi.registerTool({ +name, +label, +description, +parameters: paramType, +async execute(id, toolParams, toolSignal) { +const res = await execute(id, toolParams, toolSignal); +const truncation = truncateHead(res.content[0]?.text ?? "", { +maxLines: DEFAULT_MAX_LINES, +maxBytes: DEFAULT_MAX_BYTES, +}); +let finalText = truncation.content; +if (truncation.truncated) { +finalText += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines]`; +} +return { +content: [{ type: "text", text: finalText }], +details: res.details, +}; +}, +}); +}); +const text = formatToolList(params.server, tools); +const truncation = truncateHead(text, { +maxLines: DEFAULT_MAX_LINES, +maxBytes: DEFAULT_MAX_BYTES, +}); +let finalText = truncation.content; +if (truncation.truncated) { +finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; +} +return { +content: [{ type: "text", text: finalText }], +details: { +server: params.server, +toolCount: tools.length, +cached: false, +}, +}; +} catch (err) { +const msg = err instanceof Error ? err.message : String(err); +throw new Error( +`Failed to discover tools for "${params.server}": ${msg}`, +); +} +}, +renderCall(args, theme) { +let text = theme.fg("toolTitle", theme.bold("mcp_discover ")); +text += theme.fg("accent", args.server); +return new Text(text, 0, 0); +}, +renderResult(result, { isPartial }, theme) { +if (isPartial) +return new Text(theme.fg("warning", "Discovering tools..."), 0, 0); +const d = result.details; +return new Text( +theme.fg("success", `${d?.toolCount ?? 0} tools`) + +theme.fg("dim", ` · ${d?.server}`), +0, +0, +); +}, +}); + +// ── mcp_call ───────────────────────────────────────────────────────────── +pi.registerTool({ +name: "mcp_call", +label: "MCP Call", +description: +"Call a tool on an MCP server. Provide the server name, tool name, and arguments. " + +"Connects to the server on first call (lazy connection). " + +"Use mcp_discover first to see available tools and their required arguments.", +promptSnippet: "Call a tool on an MCP server", +promptGuidelines: [ +"Always use mcp_discover first to understand the tool's parameters before calling mcp_call.", +"Arguments are passed as a JSON object matching the tool's input schema.", +], +parameters: Type.Object({ +server: Type.String({ +description: "MCP server name, e.g. 'railway', 'twitter-mcp'", +}), +tool: Type.String({ +description: "Tool name on that server, e.g. 'railway_list_projects'", +}), +args: Type.Optional( +Type.Object( +{}, +{ +additionalProperties: true, +description: +"Tool arguments as key-value pairs matching the tool's input schema", +}, +), +), +}), +async execute(_id, params, signal) { +try { +const client = await manager.getOrConnect(params.server, signal); +const result = await client.callTool( +{ name: params.tool, arguments: params.args ?? {} }, +undefined, +{ signal, timeout: 60000 }, +); +const contentItems = result.content; +const raw = contentItems +.map((c) => (c.type === "text" ? (c.text ?? "") : JSON.stringify(c))) +.join("\n"); +const truncation = truncateHead(raw, { +maxLines: DEFAULT_MAX_LINES, +maxBytes: DEFAULT_MAX_BYTES, +}); +let finalText = truncation.content; +if (truncation.truncated) { +finalText += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; +} +return { +content: [{ type: "text", text: finalText }], +details: { +server: params.server, +tool: params.tool, +charCount: finalText.length, +truncated: truncation.truncated, +}, +}; +} catch (err) { +const msg = err instanceof Error ? err.message : String(err); +throw new Error( +`MCP call failed: ${params.server}.${params.tool}\n${msg}`, +); +} +}, +renderCall(args, theme) { +let text = theme.fg("toolTitle", theme.bold("mcp_call ")); +text += theme.fg("accent", `${args.server}.${args.tool}`); +if (args.args && Object.keys(args.args).length > 0) { +const preview = Object.entries(args.args) +.slice(0, 3) +.map(([k, v]) => { +const val = typeof v === "string" ? v : JSON.stringify(v); +return `${k}:${val.length > 30 ? val.slice(0, 30) + "…" : val}`; +}) +.join(" "); +text += " " + theme.fg("muted", preview); +} +return new Text(text, 0, 0); +}, +renderResult(result, { isPartial, expanded }, theme) { +if (isPartial) +return new Text(theme.fg("warning", "Calling MCP tool..."), 0, 0); +const d = result.details; +let text = theme.fg("success", `✓ ${d?.server}.${d?.tool}`); +text += theme.fg( +"dim", +` · ${(d?.charCount ?? 0).toLocaleString()} chars`, +); +if (d?.truncated) text += theme.fg("warning", " · truncated"); +if (expanded) { +const content = result.content[0]; +if (content?.type === "text") { +const preview = content.text.split("\n").slice(0, 15).join("\n"); +text += "\n\n" + theme.fg("dim", preview); +} +} +return new Text(text, 0, 0); +}, +}); + +// ── Lifecycle ───────────────────────────────────────────────────────────── +pi.on("session_start", async (_event, ctx) => { +const servers = manager.readConfigs(); +if (servers.length > 0) { +ctx.ui.notify( +`MCP client ready — ${servers.length} server(s) configured`, +"info", +); +} +}); +pi.on("session_shutdown", async () => { +await manager.closeAll(); +}); +pi.on("session_switch", async () => { +await manager.closeAll(); +manager.invalidateConfigCache(); +}); } diff --git a/src/tests/mcp-client-oauth.test.ts b/src/tests/mcp-client-oauth.test.ts index 54167175f..ac5e9b5bb 100644 --- a/src/tests/mcp-client-oauth.test.ts +++ b/src/tests/mcp-client-oauth.test.ts @@ -13,7 +13,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { buildHttpTransportOpts } from "../resources/extensions/mcp-client/auth.ts"; +import { buildHttpTransportOpts } from "@singularity-forge/coding-agent"; // ── Transport construction (SDK sanity checks) ───────────────────────────────