feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
This commit is contained in:
442
agent/extensions/pi-mcp-adapter/server-manager.ts
Normal file
442
agent/extensions/pi-mcp-adapter/server-manager.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
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 { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import {
|
||||
ElicitationCompleteNotificationSchema,
|
||||
type ReadResourceResult,
|
||||
type UrlElicitationRequiredError,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type {
|
||||
McpTool,
|
||||
McpResource,
|
||||
ServerDefinition,
|
||||
ServerStreamResultPatchNotification,
|
||||
Transport,
|
||||
} from "./types.ts";
|
||||
import { serverStreamResultPatchNotificationSchema } from "./types.ts";
|
||||
import { resolveNpxBinary } from "./npx-resolver.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
|
||||
import { extractOAuthConfig, supportsOAuth } from "./mcp-auth-flow.ts";
|
||||
import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.ts";
|
||||
import {
|
||||
handleUrlElicitation,
|
||||
registerElicitationHandler,
|
||||
type ServerElicitationConfig,
|
||||
} from "./elicitation-handler.ts";
|
||||
import { interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts";
|
||||
|
||||
interface ServerConnection {
|
||||
client: Client;
|
||||
transport: Transport;
|
||||
definition: ServerDefinition;
|
||||
tools: McpTool[];
|
||||
resources: McpResource[];
|
||||
lastUsedAt: number;
|
||||
inFlight: number;
|
||||
status: "connected" | "closed" | "needs-auth";
|
||||
}
|
||||
|
||||
type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
|
||||
|
||||
export class McpServerManager {
|
||||
private connections = new Map<string, ServerConnection>();
|
||||
private connectPromises = new Map<string, Promise<ServerConnection>>();
|
||||
private uiStreamListeners = new Map<string, UiStreamListener>();
|
||||
private samplingConfig: ServerSamplingConfig | undefined;
|
||||
private elicitationConfig: ServerElicitationConfig | undefined;
|
||||
private acceptedUrlElicitations = new Map<string, Set<string>>();
|
||||
|
||||
setSamplingConfig(config: ServerSamplingConfig | undefined): void {
|
||||
this.samplingConfig = config;
|
||||
}
|
||||
|
||||
setElicitationConfig(config: ServerElicitationConfig | undefined): void {
|
||||
this.elicitationConfig = config;
|
||||
}
|
||||
|
||||
async connect(name: string, definition: ServerDefinition): Promise<ServerConnection> {
|
||||
// Dedupe concurrent connection attempts
|
||||
if (this.connectPromises.has(name)) {
|
||||
return this.connectPromises.get(name)!;
|
||||
}
|
||||
|
||||
// Reuse existing connection if healthy
|
||||
const existing = this.connections.get(name);
|
||||
if (existing?.status === "connected") {
|
||||
existing.lastUsedAt = Date.now();
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = this.createConnection(name, definition);
|
||||
this.connectPromises.set(name, promise);
|
||||
|
||||
try {
|
||||
const connection = await promise;
|
||||
this.connections.set(name, connection);
|
||||
return connection;
|
||||
} finally {
|
||||
this.connectPromises.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
private async createConnection(
|
||||
name: string,
|
||||
definition: ServerDefinition
|
||||
): Promise<ServerConnection> {
|
||||
const client = this.createClient(name);
|
||||
|
||||
let transport: Transport;
|
||||
|
||||
if (definition.command) {
|
||||
let command = definition.command;
|
||||
let args = definition.args ?? [];
|
||||
|
||||
if (command === "npx" || command === "npm") {
|
||||
const resolved = await resolveNpxBinary(command, args);
|
||||
if (resolved) {
|
||||
command = resolved.isJs ? "node" : resolved.binPath;
|
||||
args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs;
|
||||
logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`);
|
||||
}
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
env: resolveEnv(definition.env),
|
||||
cwd: resolveConfigPath(definition.cwd),
|
||||
stderr: definition.debug ? "inherit" : "ignore",
|
||||
});
|
||||
} else if (definition.url) {
|
||||
// HTTP transport with fallback
|
||||
transport = await this.createHttpTransport(definition, name);
|
||||
} else {
|
||||
throw new Error(`Server ${name} has no command or url`);
|
||||
}
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
this.attachAdapterNotificationHandlers(name, client);
|
||||
|
||||
// Discover tools and resources
|
||||
const [tools, resources] = await Promise.all([
|
||||
this.fetchAllTools(client),
|
||||
this.fetchAllResources(client),
|
||||
]);
|
||||
|
||||
return {
|
||||
client,
|
||||
transport,
|
||||
definition,
|
||||
tools,
|
||||
resources,
|
||||
lastUsedAt: Date.now(),
|
||||
inFlight: 0,
|
||||
status: "connected",
|
||||
};
|
||||
} catch (error) {
|
||||
// Check for UnauthorizedError - server requires OAuth
|
||||
if (error instanceof UnauthorizedError && supportsOAuth(definition)) {
|
||||
// Clean up both client and transport before reporting needs-auth.
|
||||
await client.close().catch(() => {});
|
||||
await transport.close().catch(() => {});
|
||||
|
||||
return {
|
||||
client,
|
||||
transport,
|
||||
definition,
|
||||
tools: [],
|
||||
resources: [],
|
||||
lastUsedAt: Date.now(),
|
||||
inFlight: 0,
|
||||
status: "needs-auth",
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up both client and transport on any error
|
||||
await client.close().catch(() => {});
|
||||
await transport.close().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildClientCapabilities() {
|
||||
return {
|
||||
...(this.samplingConfig ? { sampling: {} } : {}),
|
||||
...(this.elicitationConfig
|
||||
? {
|
||||
elicitation: {
|
||||
form: {},
|
||||
...(this.elicitationConfig.allowUrl ? { url: {} } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private createClient(serverName: string): Client {
|
||||
const capabilities = this.buildClientCapabilities();
|
||||
const client = new Client(
|
||||
{ name: `pi-mcp-${serverName}`, version: "1.0.0" },
|
||||
Object.keys(capabilities).length > 0 ? { capabilities } : undefined,
|
||||
);
|
||||
if (this.samplingConfig) {
|
||||
registerSamplingHandler(client, { ...this.samplingConfig, serverName });
|
||||
}
|
||||
if (this.elicitationConfig) {
|
||||
registerElicitationHandler(client, {
|
||||
...this.elicitationConfig,
|
||||
serverName,
|
||||
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
||||
});
|
||||
if (this.elicitationConfig.allowUrl) {
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => {
|
||||
const accepted = this.acceptedUrlElicitations.get(serverName);
|
||||
if (!accepted?.delete(notification.params.elicitationId)) return;
|
||||
this.elicitationConfig?.ui.notify(
|
||||
`MCP browser interaction for ${serverName} completed. You can retry the tool now.`,
|
||||
"info",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
async handleUrlElicitationRequired(
|
||||
serverName: string,
|
||||
error: UrlElicitationRequiredError,
|
||||
): Promise<"accept" | "decline" | "cancel"> {
|
||||
if (!this.elicitationConfig?.allowUrl) return "cancel";
|
||||
for (const params of error.elicitations) {
|
||||
const result = await handleUrlElicitation({
|
||||
...this.elicitationConfig,
|
||||
serverName,
|
||||
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
||||
}, params);
|
||||
if (result.action !== "accept") return result.action;
|
||||
}
|
||||
return "accept";
|
||||
}
|
||||
|
||||
private rememberUrlElicitation(serverName: string, elicitationId: string): void {
|
||||
let accepted = this.acceptedUrlElicitations.get(serverName);
|
||||
if (!accepted) {
|
||||
accepted = new Set();
|
||||
this.acceptedUrlElicitations.set(serverName, accepted);
|
||||
}
|
||||
accepted.add(elicitationId);
|
||||
}
|
||||
|
||||
private async createHttpTransport(
|
||||
definition: ServerDefinition,
|
||||
serverName: string
|
||||
): Promise<Transport> {
|
||||
const url = new URL(definition.url!);
|
||||
|
||||
// Build headers first (including any bearer token)
|
||||
const headers = resolveHeaders(definition.headers) ?? {};
|
||||
|
||||
// For bearer auth, add the token to headers BEFORE creating requestInit
|
||||
if (definition.auth === "bearer") {
|
||||
const token = resolveBearerToken(definition);
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create request init with headers (Authorization now included for bearer auth)
|
||||
const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
|
||||
|
||||
// For OAuth servers, create an auth provider
|
||||
let authProvider: McpOAuthProvider | undefined;
|
||||
if (supportsOAuth(definition)) {
|
||||
const oauthConfig = extractOAuthConfig(definition);
|
||||
authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
definition.url!,
|
||||
oauthConfig,
|
||||
{
|
||||
onRedirect: async (_authUrl) => {
|
||||
// URL is captured by startAuth, no need to log
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Try StreamableHTTP first (modern MCP servers)
|
||||
const streamableTransport = new StreamableHTTPClientTransport(url, {
|
||||
requestInit,
|
||||
authProvider,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create a test client to verify the transport works
|
||||
const testClient = new Client({ name: "pi-mcp-probe", version: "2.1.2" });
|
||||
await testClient.connect(streamableTransport);
|
||||
await testClient.close().catch(() => {});
|
||||
// Close probe transport before creating fresh one
|
||||
await streamableTransport.close().catch(() => {});
|
||||
|
||||
// StreamableHTTP works - create fresh transport for actual use
|
||||
return new StreamableHTTPClientTransport(url, { requestInit, authProvider });
|
||||
} catch (error) {
|
||||
// StreamableHTTP failed, close and try SSE fallback
|
||||
await streamableTransport.close().catch(() => {});
|
||||
|
||||
// If this was an UnauthorizedError, don't try SSE - the server needs auth
|
||||
if (error instanceof UnauthorizedError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// SSE is the legacy transport
|
||||
return new SSEClientTransport(url, { requestInit, authProvider });
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAllTools(client: Client): Promise<McpTool[]> {
|
||||
const allTools: McpTool[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await client.listTools(cursor ? { cursor } : undefined);
|
||||
allTools.push(...(result.tools ?? []));
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return allTools;
|
||||
}
|
||||
|
||||
private async fetchAllResources(client: Client): Promise<McpResource[]> {
|
||||
try {
|
||||
const allResources: McpResource[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await client.listResources(cursor ? { cursor } : undefined);
|
||||
allResources.push(...(result.resources ?? []));
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return allResources;
|
||||
} catch {
|
||||
// Server may not support resources
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private attachAdapterNotificationHandlers(serverName: string, client: Client): void {
|
||||
client.setNotificationHandler(serverStreamResultPatchNotificationSchema, (notification) => {
|
||||
const listener = this.uiStreamListeners.get(notification.params.streamToken);
|
||||
if (!listener) return;
|
||||
listener(serverName, notification.params);
|
||||
});
|
||||
}
|
||||
|
||||
registerUiStreamListener(streamToken: string, listener: UiStreamListener): void {
|
||||
this.uiStreamListeners.set(streamToken, listener);
|
||||
}
|
||||
|
||||
removeUiStreamListener(streamToken: string): void {
|
||||
this.uiStreamListeners.delete(streamToken);
|
||||
}
|
||||
|
||||
async readResource(name: string, uri: string): Promise<ReadResourceResult> {
|
||||
const connection = this.connections.get(name);
|
||||
if (!connection || connection.status !== "connected") {
|
||||
throw new Error(`Server "${name}" is not connected`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.touch(name);
|
||||
this.incrementInFlight(name);
|
||||
return await connection.client.readResource({ uri });
|
||||
} finally {
|
||||
this.decrementInFlight(name);
|
||||
this.touch(name);
|
||||
}
|
||||
}
|
||||
|
||||
async close(name: string): Promise<void> {
|
||||
const connection = this.connections.get(name);
|
||||
if (!connection) return;
|
||||
|
||||
// Delete from map BEFORE async cleanup to prevent a race where a
|
||||
// concurrent connect() creates a new connection that our deferred
|
||||
// delete() would then remove, orphaning the new server process.
|
||||
connection.status = "closed";
|
||||
this.connections.delete(name);
|
||||
this.acceptedUrlElicitations.delete(name);
|
||||
await connection.client.close().catch(() => {});
|
||||
await connection.transport.close().catch(() => {});
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
const names = [...this.connections.keys()];
|
||||
await Promise.all(names.map(name => this.close(name)));
|
||||
}
|
||||
|
||||
getConnection(name: string): ServerConnection | undefined {
|
||||
return this.connections.get(name);
|
||||
}
|
||||
|
||||
getAllConnections(): Map<string, ServerConnection> {
|
||||
return new Map(this.connections);
|
||||
}
|
||||
|
||||
touch(name: string): void {
|
||||
const connection = this.connections.get(name);
|
||||
if (connection) {
|
||||
connection.lastUsedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
incrementInFlight(name: string): void {
|
||||
const connection = this.connections.get(name);
|
||||
if (connection) {
|
||||
connection.inFlight = (connection.inFlight ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
decrementInFlight(name: string): void {
|
||||
const connection = this.connections.get(name);
|
||||
if (connection && connection.inFlight) {
|
||||
connection.inFlight--;
|
||||
}
|
||||
}
|
||||
|
||||
isIdle(name: string, timeoutMs: number): boolean {
|
||||
const connection = this.connections.get(name);
|
||||
if (!connection || connection.status !== "connected") return false;
|
||||
if (connection.inFlight > 0) return false;
|
||||
return (Date.now() - connection.lastUsedAt) > timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve environment variables with interpolation.
|
||||
*/
|
||||
function resolveEnv(env?: Record<string, string>): Record<string, string> {
|
||||
// Copy process.env, filtering out undefined values
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
resolved[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!env) return resolved;
|
||||
|
||||
const overrides = interpolateEnvRecord(env);
|
||||
return overrides ? { ...resolved, ...overrides } : resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve headers with environment variable interpolation.
|
||||
*/
|
||||
function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
|
||||
return interpolateEnvRecord(headers);
|
||||
}
|
||||
Reference in New Issue
Block a user