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

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

View file

@ -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="<tool_name>", 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="<tool_name>", 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();
});
}

View file

@ -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) ───────────────────────────────