Files
claudecodeui/server/modules/providers/shared/mcp/mcp.provider.ts
Haileyesus b74b5fb967 refactor(providers): clarify provider auth and MCP naming
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.
2026-04-17 15:23:12 +03:00

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,
});
}
}
}