mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-19 03:51:31 +00:00
feat: implement MCP provider registry and service
- 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.
This commit is contained in:
40
server/shared/interfaces.ts
Normal file
40
server/shared/interfaces.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderMcpServer,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
|
||||
/**
|
||||
* MCP runtime contract for one provider.
|
||||
*/
|
||||
export interface IProviderMcpRuntime {
|
||||
listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>>;
|
||||
listServersForScope(scope: McpScope, options?: { workspacePath?: string }): Promise<ProviderMcpServer[]>;
|
||||
upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer>;
|
||||
removeServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }>;
|
||||
runServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
transport: McpTransport;
|
||||
reachable: boolean;
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Provider contract that both SDK and CLI families implement.
|
||||
*/
|
||||
export interface IProvider {
|
||||
readonly id: LLMProvider;
|
||||
readonly mcp: IProviderMcpRuntime;
|
||||
}
|
||||
70
server/shared/types.ts
Normal file
70
server/shared/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// -------------- HTTP API response shapes for the server, shared across modules --------------
|
||||
|
||||
export type ApiSuccessShape<TData = unknown> = {
|
||||
success: true;
|
||||
data: TData;
|
||||
};
|
||||
|
||||
export type ApiErrorShape = {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export type AppErrorOptions = {
|
||||
code?: string;
|
||||
statusCode?: number;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
// -------------------- MCP related shared types --------------------
|
||||
export type McpScope = 'user' | 'local' | 'project';
|
||||
|
||||
export type McpTransport = 'stdio' | 'http' | 'sse';
|
||||
|
||||
/**
|
||||
* Provider MCP server descriptor normalized for frontend consumption.
|
||||
*/
|
||||
export type ProviderMcpServer = {
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
transport: McpTransport;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared payload shape for MCP server create/update operations.
|
||||
*/
|
||||
export type UpsertProviderMcpServerInput = {
|
||||
name: string;
|
||||
scope?: McpScope;
|
||||
transport: McpTransport;
|
||||
workspacePath?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
};
|
||||
166
server/shared/utils.ts
Normal file
166
server/shared/utils.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import type { ApiErrorShape, ApiSuccessShape, AppErrorOptions } from '@/shared/types.js';
|
||||
|
||||
export function createApiSuccessResponse<TData>(
|
||||
data: TData,
|
||||
): ApiSuccessShape<TData> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function createApiErrorResponse(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown
|
||||
): ApiErrorShape {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function asyncHandler(
|
||||
handler: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
|
||||
): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
void Promise.resolve(handler(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
// --------- Global app error class for consistent error handling across the server ---------
|
||||
export class AppError extends Error {
|
||||
readonly code: string;
|
||||
readonly statusCode: number;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(message: string, options: AppErrorOptions = {}) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.code = options.code ?? 'INTERNAL_ERROR';
|
||||
this.statusCode = options.statusCode ?? 500;
|
||||
this.details = options.details;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
// ------------------------ The following are mainly for provider MCP runtimes ------------------------
|
||||
/**
|
||||
* Safely narrows an unknown value to a plain object record.
|
||||
*
|
||||
* This deliberately rejects arrays, `null`, and primitive values so callers can
|
||||
* treat the returned value as a JSON-style object map without repeating the same
|
||||
* defensive shape checks at every config read site.
|
||||
*/
|
||||
export const readObjectRecord = (value: unknown): Record<string, unknown> | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an optional string from unknown input and normalizes empty or whitespace-only
|
||||
* values to `undefined`.
|
||||
*
|
||||
* This is useful when parsing config files where a field may be missing, present
|
||||
* with the wrong type, or present as an empty string that should be treated as
|
||||
* "not configured".
|
||||
*/
|
||||
export const readOptionalString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an optional string array from unknown input.
|
||||
*
|
||||
* Non-array values are ignored, and any array entries that are not strings are
|
||||
* filtered out. This lets provider config readers consume loosely shaped JSON/TOML
|
||||
* data without failing on incidental invalid members.
|
||||
*/
|
||||
export const readStringArray = (value: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an optional string-to-string map from unknown input.
|
||||
*
|
||||
* The function first ensures the source value is a plain object, then keeps only
|
||||
* keys whose values are strings. If no valid entries remain, it returns `undefined`
|
||||
* so callers can distinguish "no usable map" from an empty object that was
|
||||
* intentionally authored downstream.
|
||||
*/
|
||||
export const readStringRecord = (value: unknown): Record<string, string> | undefined => {
|
||||
const record = readObjectRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
if (typeof entry === 'string') {
|
||||
normalized[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a JSON config file and guarantees a plain object result.
|
||||
*
|
||||
* Missing files are treated as an empty config object so provider-specific MCP
|
||||
* readers can operate against first-run environments without special-case file
|
||||
* existence checks. If the file exists but contains invalid JSON, the parse error
|
||||
* is preserved and rethrown.
|
||||
*/
|
||||
export const readJsonConfig = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a JSON config file with stable, human-readable formatting.
|
||||
*
|
||||
* The parent directory is created automatically so callers can persist config into
|
||||
* provider-specific folders without pre-creating the directory tree. Output always
|
||||
* ends with a trailing newline to keep the file diff-friendly.
|
||||
*/
|
||||
export const writeJsonConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user