mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-18 19:41:31 +00:00
- Add provider registry to manage LLM providers (Claude, Codex, Cursor, Gemini). - Create provider routes for MCP server operations (list, upsert, delete, run). - Implement MCP service for handling server operations and validations. - Introduce abstract provider class and MCP provider base for shared functionality. - Add tests for MCP server operations across different providers and scopes. - Define shared interfaces and types for MCP functionality. - Implement utility functions for handling JSON config files and API responses.
280 lines
8.5 KiB
TypeScript
280 lines
8.5 KiB
TypeScript
import { once } from 'node:events';
|
|
import path from 'node:path';
|
|
|
|
import spawn from 'cross-spawn';
|
|
|
|
import type { IProviderMcpRuntime } from '@/shared/interfaces.js';
|
|
import type { LLMProvider, McpScope, McpTransport, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
|
import { AppError } from '@/shared/utils.js';
|
|
|
|
const resolveWorkspacePath = (workspacePath?: string): string =>
|
|
path.resolve(workspacePath ?? process.cwd());
|
|
|
|
const normalizeServerName = (name: string): string => {
|
|
const normalized = name.trim();
|
|
if (!normalized) {
|
|
throw new AppError('MCP server name is required.', {
|
|
code: 'MCP_SERVER_NAME_REQUIRED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
return normalized;
|
|
};
|
|
|
|
const runStdioServerProbe = async (
|
|
server: ProviderMcpServer,
|
|
workspacePath: string,
|
|
): Promise<{ reachable: boolean; error?: string }> => {
|
|
if (!server.command) {
|
|
return { reachable: false, error: 'Missing stdio command.' };
|
|
}
|
|
|
|
try {
|
|
const child = spawn(server.command, server.args ?? [], {
|
|
cwd: server.cwd ? path.resolve(workspacePath, server.cwd) : workspacePath,
|
|
env: {
|
|
...process.env,
|
|
...(server.env ?? {}),
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
const timeout = setTimeout(() => {
|
|
if (!child.killed && child.exitCode === null) {
|
|
child.kill('SIGTERM');
|
|
}
|
|
}, 1_500);
|
|
|
|
const errorPromise = once(child, 'error').then(([error]) => {
|
|
throw error;
|
|
});
|
|
const closePromise = once(child, 'close');
|
|
await Promise.race([closePromise, errorPromise]);
|
|
clearTimeout(timeout);
|
|
|
|
if (typeof child.exitCode === 'number' && child.exitCode !== 0) {
|
|
return {
|
|
reachable: false,
|
|
error: `Process exited with code ${child.exitCode}.`,
|
|
};
|
|
}
|
|
|
|
return { reachable: true };
|
|
} catch (error) {
|
|
return {
|
|
reachable: false,
|
|
error: error instanceof Error ? error.message : 'Failed to start stdio process',
|
|
};
|
|
}
|
|
};
|
|
|
|
const runHttpServerProbe = async (
|
|
url: string,
|
|
): Promise<{ reachable: boolean; statusCode?: number; error?: string }> => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3_000);
|
|
try {
|
|
const response = await fetch(url, { method: 'GET', signal: controller.signal });
|
|
clearTimeout(timeout);
|
|
return {
|
|
reachable: true,
|
|
statusCode: response.status,
|
|
};
|
|
} catch (error) {
|
|
clearTimeout(timeout);
|
|
return {
|
|
reachable: false,
|
|
error: error instanceof Error ? error.message : 'Network probe failed',
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Shared MCP provider for provider-specific config readers/writers.
|
|
*/
|
|
export abstract class McpProvider implements IProviderMcpRuntime {
|
|
protected readonly provider: LLMProvider;
|
|
protected readonly supportedScopes: McpScope[];
|
|
protected readonly supportedTransports: McpTransport[];
|
|
|
|
protected constructor(
|
|
provider: LLMProvider,
|
|
supportedScopes: McpScope[],
|
|
supportedTransports: McpTransport[],
|
|
) {
|
|
this.provider = provider;
|
|
this.supportedScopes = supportedScopes;
|
|
this.supportedTransports = supportedTransports;
|
|
}
|
|
|
|
async listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>> {
|
|
const grouped: Record<McpScope, ProviderMcpServer[]> = {
|
|
user: [],
|
|
local: [],
|
|
project: [],
|
|
};
|
|
|
|
for (const scope of this.supportedScopes) {
|
|
grouped[scope] = await this.listServersForScope(scope, options);
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
async listServersForScope(
|
|
scope: McpScope,
|
|
options?: { workspacePath?: string },
|
|
): Promise<ProviderMcpServer[]> {
|
|
if (!this.supportedScopes.includes(scope)) {
|
|
return [];
|
|
}
|
|
|
|
const workspacePath = resolveWorkspacePath(options?.workspacePath);
|
|
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
|
return Object.entries(scopedServers)
|
|
.map(([name, rawConfig]) => this.normalizeServerConfig(scope, name, rawConfig))
|
|
.filter((entry): entry is ProviderMcpServer => entry !== null);
|
|
}
|
|
|
|
async upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer> {
|
|
const scope = input.scope ?? 'project';
|
|
this.assertScopeAndTransport(scope, input.transport);
|
|
|
|
const workspacePath = resolveWorkspacePath(input.workspacePath);
|
|
const normalizedName = normalizeServerName(input.name);
|
|
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
|
scopedServers[normalizedName] = this.buildServerConfig(input);
|
|
await this.writeScopedServers(scope, workspacePath, scopedServers);
|
|
|
|
return {
|
|
provider: this.provider,
|
|
name: normalizedName,
|
|
scope,
|
|
transport: input.transport,
|
|
command: input.command,
|
|
args: input.args,
|
|
env: input.env,
|
|
cwd: input.cwd,
|
|
url: input.url,
|
|
headers: input.headers,
|
|
envVars: input.envVars,
|
|
bearerTokenEnvVar: input.bearerTokenEnvVar,
|
|
envHttpHeaders: input.envHttpHeaders,
|
|
};
|
|
}
|
|
|
|
async removeServer(
|
|
input: { name: string; scope?: McpScope; workspacePath?: string },
|
|
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
|
|
const scope = input.scope ?? 'project';
|
|
this.assertScope(scope);
|
|
|
|
const workspacePath = resolveWorkspacePath(input.workspacePath);
|
|
const normalizedName = normalizeServerName(input.name);
|
|
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
|
const removed = Object.prototype.hasOwnProperty.call(scopedServers, normalizedName);
|
|
if (removed) {
|
|
delete scopedServers[normalizedName];
|
|
await this.writeScopedServers(scope, workspacePath, scopedServers);
|
|
}
|
|
|
|
return { removed, provider: this.provider, name: normalizedName, scope };
|
|
}
|
|
|
|
async runServer(
|
|
input: { name: string; scope?: McpScope; workspacePath?: string },
|
|
): Promise<{
|
|
provider: LLMProvider;
|
|
name: string;
|
|
scope: McpScope;
|
|
transport: McpTransport;
|
|
reachable: boolean;
|
|
statusCode?: number;
|
|
error?: string;
|
|
}> {
|
|
const scope = input.scope ?? 'project';
|
|
this.assertScope(scope);
|
|
|
|
const workspacePath = resolveWorkspacePath(input.workspacePath);
|
|
const normalizedName = normalizeServerName(input.name);
|
|
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
|
const rawConfig = scopedServers[normalizedName];
|
|
if (!rawConfig || typeof rawConfig !== 'object') {
|
|
throw new AppError(`MCP server "${normalizedName}" was not found.`, {
|
|
code: 'MCP_SERVER_NOT_FOUND',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const normalized = this.normalizeServerConfig(scope, normalizedName, rawConfig);
|
|
if (!normalized) {
|
|
throw new AppError(`MCP server "${normalizedName}" has an invalid configuration.`, {
|
|
code: 'MCP_SERVER_INVALID_CONFIG',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (normalized.transport === 'stdio') {
|
|
const result = await runStdioServerProbe(normalized, workspacePath);
|
|
return {
|
|
provider: this.provider,
|
|
name: normalizedName,
|
|
scope,
|
|
transport: normalized.transport,
|
|
reachable: result.reachable,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
const result = await runHttpServerProbe(normalized.url ?? '');
|
|
return {
|
|
provider: this.provider,
|
|
name: normalizedName,
|
|
scope,
|
|
transport: normalized.transport,
|
|
reachable: result.reachable,
|
|
statusCode: result.statusCode,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
protected abstract readScopedServers(
|
|
scope: McpScope,
|
|
workspacePath: string,
|
|
): Promise<Record<string, unknown>>;
|
|
|
|
protected abstract writeScopedServers(
|
|
scope: McpScope,
|
|
workspacePath: string,
|
|
servers: Record<string, unknown>,
|
|
): Promise<void>;
|
|
|
|
protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown>;
|
|
|
|
protected abstract normalizeServerConfig(
|
|
scope: McpScope,
|
|
name: string,
|
|
rawConfig: unknown,
|
|
): ProviderMcpServer | null;
|
|
|
|
protected assertScope(scope: McpScope): void {
|
|
if (!this.supportedScopes.includes(scope)) {
|
|
throw new AppError(`Provider "${this.provider}" does not support "${scope}" MCP scope.`, {
|
|
code: 'MCP_SCOPE_NOT_SUPPORTED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
}
|
|
|
|
protected assertScopeAndTransport(scope: McpScope, transport: McpTransport): void {
|
|
this.assertScope(scope);
|
|
if (!this.supportedTransports.includes(transport)) {
|
|
throw new AppError(`Provider "${this.provider}" does not support "${transport}" MCP transport.`, {
|
|
code: 'MCP_TRANSPORT_NOT_SUPPORTED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
}
|
|
}
|