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:
parent
9e484e67b7
commit
3fba4bcb03
8 changed files with 961 additions and 671 deletions
111
packages/coding-agent/src/core/mcp/auth.ts
Normal file
111
packages/coding-agent/src/core/mcp/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
105
packages/coding-agent/src/core/mcp/config.ts
Normal file
105
packages/coding-agent/src/core/mcp/config.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
328
packages/coding-agent/src/core/mcp/connection-manager.ts
Normal file
328
packages/coding-agent/src/core/mcp/connection-manager.ts
Normal 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 };
|
||||||
20
packages/coding-agent/src/core/mcp/index.ts
Normal file
20
packages/coding-agent/src/core/mcp/index.ts
Normal 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";
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) ───────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue