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>
This commit is contained in:
Mikael Hugo 2026-05-10 22:19:46 +02:00
parent 9e484e67b7
commit 3fba4bcb03
8 changed files with 961 additions and 671 deletions

View file

@ -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<string, string>;
oauth?: OAuthConfig;
}
export interface HttpTransportOptions {
authProvider?: OAuthClientProvider;
requestInit?: { headers: Record<string, string> };
}
function resolveEnvValue(value: string): string {
return value.replace(
/\$\{([^}]+)\}/g,
(_match: string, varName: string) => process.env[varName] ?? "",
);
}
function resolveHeaders(raw: Record<string, string>): Record<string, string> {
const resolved: Record<string, string> = {};
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<OAuthClientProvider["saveTokens"]>[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;
}

View file

@ -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<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
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<string>();
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<string, unknown>;
const mcpServers = (data["mcpServers"] ?? data["servers"]) as Record<string, unknown> | 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<string, unknown>;
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<string, string>) : undefined,
cwd: typeof cfg["cwd"] === "string" ? cfg["cwd"] : undefined,
}),
...(hasUrl && { url: cfg["url"] as string }),
headers: hasHeaders ? (cfg["headers"] as Record<string, string>) : 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(),
);
}

View file

@ -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<string, unknown>,
signal?: AbortSignal,
) => Promise<{
content: { type: "text"; text: string }[];
details: Record<string, unknown>;
}>;
}
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<string, ActiveConnection>();
private configCache: McpServerConfig[] | null = null;
private readonly autoRegisteredServers = new Set<string>();
private readonly toolCache = new Map<string, McpToolDefinition[]>();
/** 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<Client> {
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<void> {
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<void> {
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<string, string>): Record<string, string> {
const safe: Record<string, string> = {};
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<string, string>): Record<string, string> {
const resolved: Record<string, string> = {};
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 };

View file

@ -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";

View file

@ -434,7 +434,29 @@ export { attachJsonlLineReader, serializeJsonLine } from "./modes/rpc/jsonl.js";
// Clipboard utilities // Clipboard utilities
export { copyToClipboard } from "./utils/clipboard.js"; export { copyToClipboard } from "./utils/clipboard.js";
export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.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 // Cross-platform path display
export { toPosixPath } from "./utils/path-display.js"; export { toPosixPath } from "./utils/path-display.js";
// Shell utilities // Shell utilities
export { getShellConfig, sanitizeCommand } from "./utils/shell.js"; 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";

View file

@ -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 * The implementation now lives in packages/coding-agent/src/core/mcp/auth.ts.
* config entries so that HTTP transports can authenticate with remote * This shim keeps backward compatibility for any import of ./auth.js from
* servers (Sentry, Linear, etc.). * within the extension or from tests.
*
* Fixes #2160 MCP HTTP transport lacked an OAuth auth provider.
*/ */
// ─── Env resolution ─────────────────────────────────────────────────────────── export { buildHttpTransportOpts, createCliOAuthProvider } from "@singularity-forge/coding-agent";
/** 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;
}

View file

@ -9,312 +9,94 @@
* mcp_servers List available MCP servers from config files * mcp_servers List available MCP servers from config files
* mcp_discover Get tool signatures for a specific server (lazy connect) * mcp_discover Get tool signatures for a specific server (lazy connect)
* mcp_call Call a tool on an MCP 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 { Type } from "@sinclair/typebox";
import { import {
DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES, DEFAULT_MAX_LINES,
formatSize, McpConnectionManager,
truncateHead, formatSize,
truncateHead,
} from "@singularity-forge/coding-agent"; } from "@singularity-forge/coding-agent";
import { Text } from "@singularity-forge/tui"; import { Text } from "@singularity-forge/tui";
import { buildHttpTransportOpts } from "./auth.js";
// ─── Connection Manager ─────────────────────────────────────────────────────── // ─── Module-level manager (session-scoped) ────────────────────────────────────
const connections = new Map(); const manager = new McpConnectionManager();
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;
}
// ─── JSON Schema → TypeBox converter ───────────────────────────────────────── // ─── JSON Schema → TypeBox converter ─────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function jsonSchemaPropToTypeBox(schema) { function jsonSchemaPropToTypeBox(schema) {
if (!schema || typeof schema !== "object") return Type.Any(); if (!schema || typeof schema !== "object") return Type.Any();
const t = schema.type; const t = schema.type;
if (t === "string") return Type.String({ description: schema.description }); if (t === "string") return Type.String({ description: schema.description });
if (t === "number" || t === "integer") if (t === "number" || t === "integer")
return Type.Number({ description: schema.description }); return Type.Number({ description: schema.description });
if (t === "boolean") return Type.Boolean({ description: schema.description }); if (t === "boolean") return Type.Boolean({ description: schema.description });
if (t === "array") return Type.Array(Type.Any()); if (t === "array") return Type.Array(Type.Any());
if (t === "object") { if (t === "object") {
const props = schema.properties; const props = schema.properties;
if (props) { if (props) {
const entries = {}; const entries = {};
for (const [k, v] of Object.entries(props)) { for (const [k, v] of Object.entries(props)) {
entries[k] = jsonSchemaPropToTypeBox(v); entries[k] = jsonSchemaPropToTypeBox(v);
} }
return Type.Object(entries); return Type.Object(entries);
} }
} }
return Type.Any(); return Type.Any();
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function jsonSchemaToTypeBox(schema) { function jsonSchemaToTypeBox(schema) {
if (!schema || typeof schema !== "object") return Type.Object({}); if (!schema || typeof schema !== "object") return Type.Object({});
const obj = schema; const obj = schema;
const props = obj.properties; const props = obj.properties;
if (!props) return Type.Object({}); if (!props) return Type.Object({});
const entries = {}; const entries = {};
for (const [k, v] of Object.entries(props)) { for (const [k, v] of Object.entries(props)) {
entries[k] = jsonSchemaPropToTypeBox(v); entries[k] = jsonSchemaPropToTypeBox(v);
}
return Type.Object(entries);
} }
// ─── Dynamic MCP tool auto-registration ─────────────────────────────────────── return Type.Object(entries);
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();
} }
// ─── Formatters ─────────────────────────────────────────────────────────────── // ─── Formatters ───────────────────────────────────────────────────────────────
function formatServerList(servers) { function formatServerList(servers) {
if (servers.length === 0) if (servers.length === 0)
return "No MCP servers configured. Add servers to .mcp.json or .sf/mcp.json."; return "No MCP servers configured. Add servers to .mcp.json or .sf/mcp.json.";
const lines = [`${servers.length} MCP servers configured:\n`]; const lines = [`${servers.length} MCP servers configured:\n`];
for (const s of servers) { for (const s of servers) {
const connected = connections.has(s.name) ? "✓" : "○"; const connected = manager.isConnected(s.name) ? "✓" : "○";
const cached = toolCache.get(s.name); const cached = manager.getCachedTools(s.name);
const toolCount = cached ? `${cached.length} tools` : ""; const toolCount = cached ? `${cached.length} tools` : "";
lines.push(`${connected} ${s.name} (${s.transport})${toolCount}`); lines.push(`${connected} ${s.name} (${s.transport})${toolCount}`);
} }
lines.push( lines.push(
"\nUse mcp_discover to see full tool schemas for a specific server.", "\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)."); lines.push("Use mcp_call to invoke a tool: mcp_call(server, tool, args).");
return lines.join("\n"); return lines.join("\n");
} }
function formatToolList(serverName, tools) { function formatToolList(serverName, tools) {
const lines = [`${serverName}${tools.length} tools:\n`]; const lines = [`${serverName}${tools.length} tools:\n`];
for (const tool of tools) { for (const tool of tools) {
lines.push(`## ${tool.name}`); lines.push(`## ${tool.name}`);
if (tool.description) lines.push(tool.description); if (tool.description) lines.push(tool.description);
if (tool.inputSchema) { if (tool.inputSchema) {
lines.push("```json"); lines.push("```json");
lines.push(JSON.stringify(tool.inputSchema, null, 2)); lines.push(JSON.stringify(tool.inputSchema, null, 2));
lines.push("```"); lines.push("```");
}
lines.push("");
}
lines.push(
`Call with: mcp_call(server="${serverName}", tool="<tool_name>", args={...})`,
);
return lines.join("\n");
} }
// ─── Status helper (consumed by /sf mcp) ───────────────────────────────────── lines.push("");
}
lines.push(
`Call with: mcp_call(server="${serverName}", tool="<tool_name>", args={...})`,
);
return lines.join("\n");
}
// ─── Status helpers (consumed by /sf mcp) ────────────────────────────────────
/** /**
* Disconnect all active MCP connections and clear the tool cache. * Disconnect all active MCP connections and clear the tool cache.
* Servers will lazily reconnect on the next mcp_discover or mcp_call. * 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. * Consumer: /mcp reload command handler in commands-mcp-status.js.
*/ */
export async function disconnectAll() { 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. * Safe to call even when the server has never been connected.
*/ */
export function getConnectionStatus(name) { export function getConnectionStatus(name) {
const conn = connections.get(name); return manager.getConnectionStatus(name);
const cached = toolCache.get(name);
return {
connected: !!conn,
tools: cached ? cached.map((t) => t.name) : [],
error: undefined,
};
} }
// ─── Test-exported helpers ──────────────────────────────────────────────────── // ─── Test-exported helpers ────────────────────────────────────────────────────
export function _buildMcpChildEnvForTest(env) { export function _buildMcpChildEnvForTest(env) {
return buildChildEnv(env); return manager.buildChildEnv(env);
} }
export function _buildMcpTrustConfirmOptionsForTest(signal) { export function _buildMcpTrustConfirmOptionsForTest(signal) {
return { timeout: 120_000, signal }; return { timeout: 120_000, signal };
} }
// ─── Extension ──────────────────────────────────────────────────────────────── // ─── Extension ────────────────────────────────────────────────────────────────
export default function (pi) { export default function (pi) {
// ── mcp_servers ────────────────────────────────────────────────────────── // ── mcp_servers ──────────────────────────────────────────────────────────
pi.registerTool({ pi.registerTool({
name: "mcp_servers", name: "mcp_servers",
label: "MCP Servers", label: "MCP Servers",
description: description:
"List all available MCP servers configured in project files (.mcp.json, .sf/mcp.json). " + "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 " + "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).", "tools are auto-registered as first-class pi tools (e.g. serena_find_symbol).",
promptSnippet: "List available MCP servers from project configuration", promptSnippet: "List available MCP servers from project configuration",
promptGuidelines: [ promptGuidelines: [
"Call mcp_servers to see what MCP servers are available before trying to use one.", "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.", "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.", "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.", "After listing, use mcp_discover(server) to get tool schemas, then mcp_call(server, tool, args) to invoke.",
], ],
parameters: Type.Object({ parameters: Type.Object({
refresh: Type.Optional( refresh: Type.Optional(
Type.Boolean({ Type.Boolean({
description: "Force refresh the server list (default: use cache)", description: "Force refresh the server list (default: use cache)",
}), }),
), ),
}), }),
async execute(_id, params) { async execute(_id, params) {
if (params.refresh) configCache = null; if (params.refresh) manager.invalidateConfigCache();
const servers = readConfigs(); const servers = manager.readConfigs();
return { return {
content: [{ type: "text", text: formatServerList(servers) }], content: [{ type: "text", text: formatServerList(servers) }],
details: { details: {
serverCount: servers.length, serverCount: servers.length,
cached: !params.refresh && configCache !== null, cached: !params.refresh,
}, },
}; };
}, },
renderCall(args, theme) { renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("mcp_servers")); let text = theme.fg("toolTitle", theme.bold("mcp_servers"));
if (args.refresh) text += theme.fg("warning", " (refresh)"); if (args.refresh) text += theme.fg("warning", " (refresh)");
return new Text(text, 0, 0); return new Text(text, 0, 0);
}, },
renderResult(result, { isPartial }, theme) { renderResult(result, { isPartial }, theme) {
if (isPartial) if (isPartial)
return new Text(theme.fg("warning", "Reading MCP config..."), 0, 0); return new Text(theme.fg("warning", "Reading MCP config..."), 0, 0);
const d = result.details; const d = result.details;
return new Text( return new Text(
theme.fg("success", `${d?.serverCount ?? 0} servers configured`), theme.fg("success", `${d?.serverCount ?? 0} servers configured`),
0, 0,
0, 0,
); );
}, },
}); });
// ── mcp_discover ─────────────────────────────────────────────────────────
pi.registerTool({ // ── mcp_discover ─────────────────────────────────────────────────────────
name: "mcp_discover", pi.registerTool({
label: "MCP Discover", name: "mcp_discover",
description: label: "MCP Discover",
"Get detailed tool signatures and JSON schemas for a specific MCP server. " + description:
"Connects to the server on first call (lazy connection). " + "Get detailed tool signatures and JSON schemas for a specific MCP server. " +
"After discovery, each MCP tool is auto-registered as a first-class pi tool " + "Connects to the server on first call (lazy connection). " +
"(e.g. serena_find_symbol) — the LLM can call them directly without mcp_call.", "After discovery, each MCP tool is auto-registered as a first-class pi tool " +
promptSnippet: "(e.g. serena_find_symbol) — the LLM can call them directly without mcp_call.",
"Discover MCP server tools and register them as first-class pi tools", promptSnippet:
promptGuidelines: [ "Discover MCP server tools and register them as first-class pi tools",
"Call mcp_discover(server) to connect to an MCP server and surface its tools.", promptGuidelines: [
"After discovery, the LLM sees each tool by its real name (e.g. serena_search_for_pattern).", "Call mcp_discover(server) to connect to an MCP server and surface its tools.",
"Call tools directly by their names instead of going through mcp_call.", "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({ parameters: Type.Object({
description: server: Type.String({
"MCP server name (from mcp_servers output), e.g. 'railway', 'twitter-mcp', 'linear'", description:
}), "MCP server name (from mcp_servers output), e.g. 'railway', 'twitter-mcp', 'linear'",
}), }),
async execute(_id, params, signal) { }),
try { async execute(_id, params, signal) {
// Return cached tools if available try {
const cached = toolCache.get(params.server); const cached = manager.getCachedTools(params.server);
if (cached) { if (cached) {
const text = formatToolList(params.server, cached); const text = formatToolList(params.server, cached);
const truncation = truncateHead(text, { const truncation = truncateHead(text, {
maxLines: DEFAULT_MAX_LINES, maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES, maxBytes: DEFAULT_MAX_BYTES,
}); });
let finalText = truncation.content; let finalText = truncation.content;
if (truncation.truncated) { if (truncation.truncated) {
finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
} }
return { return {
content: [{ type: "text", text: finalText }], content: [{ type: "text", text: finalText }],
details: { details: {
server: params.server, server: params.server,
toolCount: cached.length, toolCount: cached.length,
cached: true, cached: true,
}, },
}; };
} }
const client = await getOrConnect(params.server, signal); const client = await manager.getOrConnect(params.server, signal);
const result = await client.listTools(undefined, { const result = await client.listTools(undefined, {
signal, signal,
timeout: 30000, timeout: 30000,
}); });
const tools = (result.tools ?? []).map((t) => ({ const tools = (result.tools ?? []).map((t) => ({
name: t.name, name: t.name,
description: t.description ?? "", description: t.description ?? "",
inputSchema: t.inputSchema, inputSchema: t.inputSchema,
})); }));
toolCache.set(params.server, tools); manager.setCachedTools(params.server, tools);
// Auto-register each MCP tool as a first-class pi tool. // Auto-register each MCP tool as a first-class pi tool.
// After this, the LLM sees e.g. serena_find_symbol directly instead manager.registerToolsForServer(params.server, tools, ({ name, label, description, inputSchemaRaw, execute }) => {
// of going through the generic mcp_call indirection. const paramType = inputSchemaRaw
registerMcpToolsForServer(pi, params.server, tools); ? jsonSchemaToTypeBox(inputSchemaRaw)
const text = formatToolList(params.server, tools); : Type.Object({});
const truncation = truncateHead(text, { pi.registerTool({
maxLines: DEFAULT_MAX_LINES, name,
maxBytes: DEFAULT_MAX_BYTES, label,
}); description,
let finalText = truncation.content; parameters: paramType,
if (truncation.truncated) { async execute(id, toolParams, toolSignal) {
finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; const res = await execute(id, toolParams, toolSignal);
} const truncation = truncateHead(res.content[0]?.text ?? "", {
return { maxLines: DEFAULT_MAX_LINES,
content: [{ type: "text", text: finalText }], maxBytes: DEFAULT_MAX_BYTES,
details: { });
server: params.server, let finalText = truncation.content;
toolCount: tools.length, if (truncation.truncated) {
cached: false, finalText += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines]`;
}, }
}; return {
} catch (err) { content: [{ type: "text", text: finalText }],
const msg = err instanceof Error ? err.message : String(err); details: res.details,
throw new Error( };
`Failed to discover tools for "${params.server}": ${msg}`, },
); });
} });
}, const text = formatToolList(params.server, tools);
renderCall(args, theme) { const truncation = truncateHead(text, {
let text = theme.fg("toolTitle", theme.bold("mcp_discover ")); maxLines: DEFAULT_MAX_LINES,
text += theme.fg("accent", args.server); maxBytes: DEFAULT_MAX_BYTES,
return new Text(text, 0, 0); });
}, let finalText = truncation.content;
renderResult(result, { isPartial }, theme) { if (truncation.truncated) {
if (isPartial) finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
return new Text(theme.fg("warning", "Discovering tools..."), 0, 0); }
const d = result.details; return {
return new Text( content: [{ type: "text", text: finalText }],
theme.fg("success", `${d?.toolCount ?? 0} tools`) + details: {
theme.fg("dim", ` · ${d?.server}`), server: params.server,
0, toolCount: tools.length,
0, cached: false,
); },
}, };
}); } catch (err) {
// ── mcp_call ───────────────────────────────────────────────────────────── const msg = err instanceof Error ? err.message : String(err);
pi.registerTool({ throw new Error(
name: "mcp_call", `Failed to discover tools for "${params.server}": ${msg}`,
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). " + renderCall(args, theme) {
"Use mcp_discover first to see available tools and their required arguments.", let text = theme.fg("toolTitle", theme.bold("mcp_discover "));
promptSnippet: "Call a tool on an MCP server", text += theme.fg("accent", args.server);
promptGuidelines: [ return new Text(text, 0, 0);
"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.", renderResult(result, { isPartial }, theme) {
], if (isPartial)
parameters: Type.Object({ return new Text(theme.fg("warning", "Discovering tools..."), 0, 0);
server: Type.String({ const d = result.details;
description: "MCP server name, e.g. 'railway', 'twitter-mcp'", return new Text(
}), theme.fg("success", `${d?.toolCount ?? 0} tools`) +
tool: Type.String({ theme.fg("dim", ` · ${d?.server}`),
description: "Tool name on that server, e.g. 'railway_list_projects'", 0,
}), 0,
args: Type.Optional( );
Type.Object( },
{}, });
{
additionalProperties: true, // ── mcp_call ─────────────────────────────────────────────────────────────
description: pi.registerTool({
"Tool arguments as key-value pairs matching the tool's input schema", 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). " +
async execute(_id, params, signal) { "Use mcp_discover first to see available tools and their required arguments.",
try { promptSnippet: "Call a tool on an MCP server",
const client = await getOrConnect(params.server, signal); promptGuidelines: [
const result = await client.callTool( "Always use mcp_discover first to understand the tool's parameters before calling mcp_call.",
{ name: params.tool, arguments: params.args ?? {} }, "Arguments are passed as a JSON object matching the tool's input schema.",
undefined, ],
{ signal, timeout: 60000 }, parameters: Type.Object({
); server: Type.String({
// Serialize result content to text description: "MCP server name, e.g. 'railway', 'twitter-mcp'",
const contentItems = result.content; }),
const raw = contentItems tool: Type.String({
.map((c) => (c.type === "text" ? (c.text ?? "") : JSON.stringify(c))) description: "Tool name on that server, e.g. 'railway_list_projects'",
.join("\n"); }),
const truncation = truncateHead(raw, { args: Type.Optional(
maxLines: DEFAULT_MAX_LINES, Type.Object(
maxBytes: DEFAULT_MAX_BYTES, {},
}); {
let finalText = truncation.content; additionalProperties: true,
if (truncation.truncated) { description:
finalText += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; "Tool arguments as key-value pairs matching the tool's input schema",
} },
return { ),
content: [{ type: "text", text: finalText }], ),
details: { }),
server: params.server, async execute(_id, params, signal) {
tool: params.tool, try {
charCount: finalText.length, const client = await manager.getOrConnect(params.server, signal);
truncated: truncation.truncated, const result = await client.callTool(
}, { name: params.tool, arguments: params.args ?? {} },
}; undefined,
} catch (err) { { signal, timeout: 60000 },
const msg = err instanceof Error ? err.message : String(err); );
throw new Error( const contentItems = result.content;
`MCP call failed: ${params.server}.${params.tool}\n${msg}`, const raw = contentItems
); .map((c) => (c.type === "text" ? (c.text ?? "") : JSON.stringify(c)))
} .join("\n");
}, const truncation = truncateHead(raw, {
renderCall(args, theme) { maxLines: DEFAULT_MAX_LINES,
let text = theme.fg("toolTitle", theme.bold("mcp_call ")); maxBytes: DEFAULT_MAX_BYTES,
text += theme.fg("accent", `${args.server}.${args.tool}`); });
if (args.args && Object.keys(args.args).length > 0) { let finalText = truncation.content;
const preview = Object.entries(args.args) if (truncation.truncated) {
.slice(0, 3) finalText += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
.map(([k, v]) => { }
const val = typeof v === "string" ? v : JSON.stringify(v); return {
return `${k}:${val.length > 30 ? val.slice(0, 30) + "…" : val}`; content: [{ type: "text", text: finalText }],
}) details: {
.join(" "); server: params.server,
text += " " + theme.fg("muted", preview); tool: params.tool,
} charCount: finalText.length,
return new Text(text, 0, 0); truncated: truncation.truncated,
}, },
renderResult(result, { isPartial, expanded }, theme) { };
if (isPartial) } catch (err) {
return new Text(theme.fg("warning", "Calling MCP tool..."), 0, 0); const msg = err instanceof Error ? err.message : String(err);
const d = result.details; throw new Error(
let text = theme.fg("success", `${d?.server}.${d?.tool}`); `MCP call failed: ${params.server}.${params.tool}\n${msg}`,
text += theme.fg( );
"dim", }
` · ${(d?.charCount ?? 0).toLocaleString()} chars`, },
); renderCall(args, theme) {
if (d?.truncated) text += theme.fg("warning", " · truncated"); let text = theme.fg("toolTitle", theme.bold("mcp_call "));
if (expanded) { text += theme.fg("accent", `${args.server}.${args.tool}`);
const content = result.content[0]; if (args.args && Object.keys(args.args).length > 0) {
if (content?.type === "text") { const preview = Object.entries(args.args)
const preview = content.text.split("\n").slice(0, 15).join("\n"); .slice(0, 3)
text += "\n\n" + theme.fg("dim", preview); .map(([k, v]) => {
} const val = typeof v === "string" ? v : JSON.stringify(v);
} return `${k}:${val.length > 30 ? val.slice(0, 30) + "…" : val}`;
return new Text(text, 0, 0); })
}, .join(" ");
}); text += " " + theme.fg("muted", preview);
// ── Lifecycle ───────────────────────────────────────────────────────────── }
pi.on("session_start", async (_event, ctx) => { return new Text(text, 0, 0);
const servers = readConfigs(); },
if (servers.length > 0) { renderResult(result, { isPartial, expanded }, theme) {
ctx.ui.notify( if (isPartial)
`MCP client ready — ${servers.length} server(s) configured`, return new Text(theme.fg("warning", "Calling MCP tool..."), 0, 0);
"info", const d = result.details;
); let text = theme.fg("success", `${d?.server}.${d?.tool}`);
} text += theme.fg(
}); "dim",
pi.on("session_shutdown", async () => { ` · ${(d?.charCount ?? 0).toLocaleString()} chars`,
await closeAll(); );
}); if (d?.truncated) text += theme.fg("warning", " · truncated");
pi.on("session_switch", async () => { if (expanded) {
await closeAll(); const content = result.content[0];
configCache = null; 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();
});
} }

View file

@ -13,7 +13,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "vitest"; 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) ─────────────────────────────── // ── Transport construction (SDK sanity checks) ───────────────────────────────