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
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -9,132 +9,23 @@
|
|||
* 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,
|
||||
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) {
|
||||
|
|
@ -169,125 +60,15 @@ function jsonSchemaToTypeBox(schema) {
|
|||
}
|
||||
return Type.Object(entries);
|
||||
}
|
||||
// ─── 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();
|
||||
}
|
||||
|
||||
// ─── 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 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}`);
|
||||
}
|
||||
|
|
@ -314,7 +95,8 @@ function formatToolList(serverName, tools) {
|
|||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
// ─── Status helper (consumed by /sf mcp) ─────────────────────────────────────
|
||||
|
||||
// ─── 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,21 +113,17 @@ 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 };
|
||||
}
|
||||
|
||||
// ─── Extension ────────────────────────────────────────────────────────────────
|
||||
export default function (pi) {
|
||||
// ── mcp_servers ──────────────────────────────────────────────────────────
|
||||
|
|
@ -371,13 +149,13 @@ export default function (pi) {
|
|||
),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
if (params.refresh) configCache = null;
|
||||
const servers = readConfigs();
|
||||
if (params.refresh) manager.invalidateConfigCache();
|
||||
const servers = manager.readConfigs();
|
||||
return {
|
||||
content: [{ type: "text", text: formatServerList(servers) }],
|
||||
details: {
|
||||
serverCount: servers.length,
|
||||
cached: !params.refresh && configCache !== null,
|
||||
cached: !params.refresh,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
@ -397,6 +175,7 @@ export default function (pi) {
|
|||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ── mcp_discover ─────────────────────────────────────────────────────────
|
||||
pi.registerTool({
|
||||
name: "mcp_discover",
|
||||
|
|
@ -421,8 +200,7 @@ export default function (pi) {
|
|||
}),
|
||||
async execute(_id, params, signal) {
|
||||
try {
|
||||
// Return cached tools if available
|
||||
const cached = toolCache.get(params.server);
|
||||
const cached = manager.getCachedTools(params.server);
|
||||
if (cached) {
|
||||
const text = formatToolList(params.server, cached);
|
||||
const truncation = truncateHead(text, {
|
||||
|
|
@ -442,7 +220,7 @@ export default function (pi) {
|
|||
},
|
||||
};
|
||||
}
|
||||
const client = await getOrConnect(params.server, signal);
|
||||
const client = await manager.getOrConnect(params.server, signal);
|
||||
const result = await client.listTools(undefined, {
|
||||
signal,
|
||||
timeout: 30000,
|
||||
|
|
@ -452,11 +230,34 @@ export default function (pi) {
|
|||
description: t.description ?? "",
|
||||
inputSchema: t.inputSchema,
|
||||
}));
|
||||
toolCache.set(params.server, tools);
|
||||
manager.setCachedTools(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);
|
||||
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,
|
||||
|
|
@ -498,6 +299,7 @@ export default function (pi) {
|
|||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ── mcp_call ─────────────────────────────────────────────────────────────
|
||||
pi.registerTool({
|
||||
name: "mcp_call",
|
||||
|
|
@ -531,13 +333,12 @@ export default function (pi) {
|
|||
}),
|
||||
async execute(_id, params, signal) {
|
||||
try {
|
||||
const client = await getOrConnect(params.server, signal);
|
||||
const client = await manager.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)))
|
||||
|
|
@ -601,9 +402,10 @@ export default function (pi) {
|
|||
return new Text(text, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const servers = readConfigs();
|
||||
const servers = manager.readConfigs();
|
||||
if (servers.length > 0) {
|
||||
ctx.ui.notify(
|
||||
`MCP client ready — ${servers.length} server(s) configured`,
|
||||
|
|
@ -612,10 +414,10 @@ export default function (pi) {
|
|||
}
|
||||
});
|
||||
pi.on("session_shutdown", async () => {
|
||||
await closeAll();
|
||||
await manager.closeAll();
|
||||
});
|
||||
pi.on("session_switch", async () => {
|
||||
await closeAll();
|
||||
configCache = null;
|
||||
await manager.closeAll();
|
||||
manager.invalidateConfigCache();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ───────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue