mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-19 12:01:28 +00:00
Rename provider auth/MCP contracts to remove the overloaded Runtime suffix so the shared interfaces read as stable provider capabilities instead of execution implementation details. Add a consistent provider-first auth class naming convention by renaming ClaudeAuthProvider, CodexAuthProvider, CursorAuthProvider, and GeminiAuthProvider to ClaudeProviderAuth, CodexProviderAuth, CursorProviderAuth, and GeminiProviderAuth. This keeps the provider module API easier to scan and aligns auth naming with the main provider ownership model.
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 { IProviderMcp } 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 IProviderMcp {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|