diff --git a/eslint.config.js b/eslint.config.js index 3ef25d92..6d1eac08 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -148,9 +148,17 @@ export default tseslint.config( ], "boundaries/elements": [ { - type: "backend-shared-types", // shared backend type contract that modules may consume without creating runtime coupling - pattern: ["server/shared/types.{js,ts}"], // support the current shared types path - mode: "file", // treat the types file itself as the boundary element instead of the whole folder + type: "backend-shared-type-contract", // shared backend type/interface contracts that modules may consume without creating runtime coupling + pattern: [ + "server/shared/types.{js,ts}", + "server/shared/interfaces.{js,ts}", + ], // keep backend modules on explicit shared contract files for erased imports only + mode: "file", // treat each shared contract file itself as the boundary element instead of the whole folder + }, + { + type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly + pattern: ["server/shared/utils.{js,ts}"], // classify the shared utils file so modules can depend on it explicitly + mode: "file", }, { type: "backend-module", // logical element name used by boundaries rules below @@ -196,13 +204,13 @@ export default tseslint.config( checkInternals: false, // do not apply these cross-module rules to imports inside the same module rules: [ { - from: { type: "backend-module" }, // modules may depend on the shared types contract only as erased type-only imports - to: { type: "backend-shared-types" }, + from: { type: "backend-module" }, // modules may depend on shared type/interface contracts only as erased type-only imports + to: { type: "backend-shared-type-contract" }, disallow: { dependency: { kind: ["value", "typeof"] }, - }, // block runtime imports so shared types stay a compile-time contract instead of a hidden shared module + }, // block runtime imports so shared contracts stay compile-time only instead of becoming hidden shared modules message: - "Backend modules may only use `import type` when importing from server/shared/types.ts (or server/types.ts).", + "Backend modules may only use `import type` when importing from server/shared/types.ts or server/shared/interfaces.ts.", }, { to: { type: "backend-module" }, // when importing anything that belongs to another backend module diff --git a/package-lock.json b/package-lock.json index 0905f759..1f383186 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,8 @@ "@commitlint/config-conventional": "^20.4.3", "@eslint/js": "^9.39.3", "@release-it/conventional-changelog": "^10.0.5", + "@types/cross-spawn": "^6.0.6", + "@types/express": "^5.0.6", "@types/node": "^22.19.7", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", @@ -3752,6 +3754,37 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3776,6 +3809,31 @@ "@types/estree": "*" } }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -3785,6 +3843,13 @@ "@types/unist": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3843,6 +3908,20 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", @@ -3863,6 +3942,27 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", diff --git a/package.json b/package.json index b4d2653f..1bbfb91d 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,8 @@ "@commitlint/config-conventional": "^20.4.3", "@eslint/js": "^9.39.3", "@release-it/conventional-changelog": "^10.0.5", + "@types/cross-spawn": "^6.0.6", + "@types/express": "^5.0.6", "@types/node": "^22.19.7", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", diff --git a/server/index.js b/server/index.js index 3e5e657d..5f7e7a4e 100755 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,9 @@ import fs from 'fs'; import path from 'path'; import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; +import { AppError } from '@/shared/utils.js'; + + const __dirname = getModuleDir(import.meta.url); // The server source runs from /server, while the compiled output runs from /dist-server/server. // Resolving the app root once keeps every repo-level lookup below aligned across both layouts. @@ -2289,6 +2292,30 @@ app.get('*', (req, res) => { } }); +// global error middleware must be last +app.use((err, req, res, next) => { + if (err instanceof AppError) { + return res.status(err.statusCode).json({ + success: false, + error: { + code: err.code, + message: err.message, + details: err.details, + }, + }); + } + + console.error(err); + + return res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_ERROR', + message: 'Internal server error', + }, + }); +}); + // Helper function to convert permissions to rwx format function permToRwx(perm) { const r = perm & 4 ? 'r' : '-'; diff --git a/server/modules/providers/list/claude/claude-mcp.provider.ts b/server/modules/providers/list/claude/claude-mcp.provider.ts new file mode 100644 index 00000000..fb4b4ac5 --- /dev/null +++ b/server/modules/providers/list/claude/claude-mcp.provider.ts @@ -0,0 +1,135 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; +import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; +import { + AppError, + readJsonConfig, + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, + writeJsonConfig, +} from '@/shared/utils.js'; + +export class ClaudeMcpProvider extends McpProvider { + constructor() { + super('claude', ['user', 'local', 'project'], ['stdio', 'http', 'sse']); + } + + protected async readScopedServers(scope: McpScope, workspacePath: string): Promise> { + if (scope === 'project') { + const filePath = path.join(workspacePath, '.mcp.json'); + const config = await readJsonConfig(filePath); + return readObjectRecord(config.mcpServers) ?? {}; + } + + const filePath = path.join(os.homedir(), '.claude.json'); + const config = await readJsonConfig(filePath); + if (scope === 'user') { + return readObjectRecord(config.mcpServers) ?? {}; + } + + const projects = readObjectRecord(config.projects) ?? {}; + const projectConfig = readObjectRecord(projects[workspacePath]) ?? {}; + return readObjectRecord(projectConfig.mcpServers) ?? {}; + } + + protected async writeScopedServers( + scope: McpScope, + workspacePath: string, + servers: Record, + ): Promise { + if (scope === 'project') { + const filePath = path.join(workspacePath, '.mcp.json'); + const config = await readJsonConfig(filePath); + config.mcpServers = servers; + await writeJsonConfig(filePath, config); + return; + } + + const filePath = path.join(os.homedir(), '.claude.json'); + const config = await readJsonConfig(filePath); + if (scope === 'user') { + config.mcpServers = servers; + await writeJsonConfig(filePath, config); + return; + } + + const projects = readObjectRecord(config.projects) ?? {}; + const projectConfig = readObjectRecord(projects[workspacePath]) ?? {}; + projectConfig.mcpServers = servers; + projects[workspacePath] = projectConfig; + config.projects = projects; + await writeJsonConfig(filePath, config); + } + + protected buildServerConfig(input: UpsertProviderMcpServerInput): Record { + if (input.transport === 'stdio') { + if (!input.command?.trim()) { + throw new AppError('command is required for stdio MCP servers.', { + code: 'MCP_COMMAND_REQUIRED', + statusCode: 400, + }); + } + + return { + type: 'stdio', + command: input.command, + args: input.args ?? [], + env: input.env ?? {}, + }; + } + + if (!input.url?.trim()) { + throw new AppError('url is required for http/sse MCP servers.', { + code: 'MCP_URL_REQUIRED', + statusCode: 400, + }); + } + + return { + type: input.transport, + url: input.url, + headers: input.headers ?? {}, + }; + } + + protected normalizeServerConfig( + scope: McpScope, + name: string, + rawConfig: unknown, + ): ProviderMcpServer | null { + if (!rawConfig || typeof rawConfig !== 'object') { + return null; + } + + const config = rawConfig as Record; + if (typeof config.command === 'string') { + return { + provider: 'claude', + name, + scope, + transport: 'stdio', + command: config.command, + args: readStringArray(config.args), + env: readStringRecord(config.env), + }; + } + + if (typeof config.url === 'string') { + const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http'; + return { + provider: 'claude', + name, + scope, + transport, + url: config.url, + headers: readStringRecord(config.headers), + }; + } + + return null; + } +} diff --git a/server/modules/providers/list/claude/claude.provider.ts b/server/modules/providers/list/claude/claude.provider.ts new file mode 100644 index 00000000..b620ad1f --- /dev/null +++ b/server/modules/providers/list/claude/claude.provider.ts @@ -0,0 +1,10 @@ +import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js'; + +export class ClaudeProvider extends AbstractProvider { + readonly mcp = new ClaudeMcpProvider(); + + constructor() { + super('claude'); + } +} diff --git a/server/modules/providers/list/codex/codex-mcp.provider.ts b/server/modules/providers/list/codex/codex-mcp.provider.ts new file mode 100644 index 00000000..1aeef5d1 --- /dev/null +++ b/server/modules/providers/list/codex/codex-mcp.provider.ts @@ -0,0 +1,135 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import TOML from '@iarna/toml'; + +import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; +import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; +import { + AppError, + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, +} from '@/shared/utils.js'; + +const readTomlConfig = async (filePath: string): Promise> => { + try { + const content = await readFile(filePath, 'utf8'); + const parsed = TOML.parse(content) as Record; + return readObjectRecord(parsed) ?? {}; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return {}; + } + throw error; + } +}; + +const writeTomlConfig = async (filePath: string, data: Record): Promise => { + await mkdir(path.dirname(filePath), { recursive: true }); + const toml = TOML.stringify(data as never); + await writeFile(filePath, toml, 'utf8'); +}; + +export class CodexMcpProvider extends McpProvider { + constructor() { + super('codex', ['user', 'project'], ['stdio', 'http']); + } + + protected async readScopedServers(scope: McpScope, workspacePath: string): Promise> { + const filePath = scope === 'user' + ? path.join(os.homedir(), '.codex', 'config.toml') + : path.join(workspacePath, '.codex', 'config.toml'); + const config = await readTomlConfig(filePath); + return readObjectRecord(config.mcp_servers) ?? {}; + } + + protected async writeScopedServers( + scope: McpScope, + workspacePath: string, + servers: Record, + ): Promise { + const filePath = scope === 'user' + ? path.join(os.homedir(), '.codex', 'config.toml') + : path.join(workspacePath, '.codex', 'config.toml'); + const config = await readTomlConfig(filePath); + config.mcp_servers = servers; + await writeTomlConfig(filePath, config); + } + + protected buildServerConfig(input: UpsertProviderMcpServerInput): Record { + if (input.transport === 'stdio') { + if (!input.command?.trim()) { + throw new AppError('command is required for stdio MCP servers.', { + code: 'MCP_COMMAND_REQUIRED', + statusCode: 400, + }); + } + + return { + command: input.command, + args: input.args ?? [], + env: input.env ?? {}, + env_vars: input.envVars ?? [], + cwd: input.cwd, + }; + } + + if (!input.url?.trim()) { + throw new AppError('url is required for http MCP servers.', { + code: 'MCP_URL_REQUIRED', + statusCode: 400, + }); + } + + return { + url: input.url, + bearer_token_env_var: input.bearerTokenEnvVar, + http_headers: input.headers ?? {}, + env_http_headers: input.envHttpHeaders ?? {}, + }; + } + + protected normalizeServerConfig( + scope: McpScope, + name: string, + rawConfig: unknown, + ): ProviderMcpServer | null { + if (!rawConfig || typeof rawConfig !== 'object') { + return null; + } + + const config = rawConfig as Record; + if (typeof config.command === 'string') { + return { + provider: 'codex', + name, + scope, + transport: 'stdio', + command: config.command, + args: readStringArray(config.args), + env: readStringRecord(config.env), + cwd: readOptionalString(config.cwd), + envVars: readStringArray(config.env_vars), + }; + } + + if (typeof config.url === 'string') { + return { + provider: 'codex', + name, + scope, + transport: 'http', + url: config.url, + headers: readStringRecord(config.http_headers), + bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var), + envHttpHeaders: readStringRecord(config.env_http_headers), + }; + } + + return null; + } +} diff --git a/server/modules/providers/list/codex/codex.provider.ts b/server/modules/providers/list/codex/codex.provider.ts new file mode 100644 index 00000000..3abfa8d9 --- /dev/null +++ b/server/modules/providers/list/codex/codex.provider.ts @@ -0,0 +1,10 @@ +import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js'; + +export class CodexProvider extends AbstractProvider { + readonly mcp = new CodexMcpProvider(); + + constructor() { + super('codex'); + } +} diff --git a/server/modules/providers/list/cursor/cursor-mcp.provider.ts b/server/modules/providers/list/cursor/cursor-mcp.provider.ts new file mode 100644 index 00000000..107792c5 --- /dev/null +++ b/server/modules/providers/list/cursor/cursor-mcp.provider.ts @@ -0,0 +1,108 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; +import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; +import { + AppError, + readJsonConfig, + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, + writeJsonConfig, +} from '@/shared/utils.js'; + +export class CursorMcpProvider extends McpProvider { + constructor() { + super('cursor', ['user', 'project'], ['stdio', 'http', 'sse']); + } + + protected async readScopedServers(scope: McpScope, workspacePath: string): Promise> { + const filePath = scope === 'user' + ? path.join(os.homedir(), '.cursor', 'mcp.json') + : path.join(workspacePath, '.cursor', 'mcp.json'); + const config = await readJsonConfig(filePath); + return readObjectRecord(config.mcpServers) ?? {}; + } + + protected async writeScopedServers( + scope: McpScope, + workspacePath: string, + servers: Record, + ): Promise { + const filePath = scope === 'user' + ? path.join(os.homedir(), '.cursor', 'mcp.json') + : path.join(workspacePath, '.cursor', 'mcp.json'); + const config = await readJsonConfig(filePath); + config.mcpServers = servers; + await writeJsonConfig(filePath, config); + } + + protected buildServerConfig(input: UpsertProviderMcpServerInput): Record { + if (input.transport === 'stdio') { + if (!input.command?.trim()) { + throw new AppError('command is required for stdio MCP servers.', { + code: 'MCP_COMMAND_REQUIRED', + statusCode: 400, + }); + } + + return { + command: input.command, + args: input.args ?? [], + env: input.env ?? {}, + cwd: input.cwd, + }; + } + + if (!input.url?.trim()) { + throw new AppError('url is required for http/sse MCP servers.', { + code: 'MCP_URL_REQUIRED', + statusCode: 400, + }); + } + + return { + url: input.url, + headers: input.headers ?? {}, + }; + } + + protected normalizeServerConfig( + scope: McpScope, + name: string, + rawConfig: unknown, + ): ProviderMcpServer | null { + if (!rawConfig || typeof rawConfig !== 'object') { + return null; + } + + const config = rawConfig as Record; + if (typeof config.command === 'string') { + return { + provider: 'cursor', + name, + scope, + transport: 'stdio', + command: config.command, + args: readStringArray(config.args), + env: readStringRecord(config.env), + cwd: readOptionalString(config.cwd), + }; + } + + if (typeof config.url === 'string') { + return { + provider: 'cursor', + name, + scope, + transport: 'http', + url: config.url, + headers: readStringRecord(config.headers), + }; + } + + return null; + } +} diff --git a/server/modules/providers/list/cursor/cursor.provider.ts b/server/modules/providers/list/cursor/cursor.provider.ts new file mode 100644 index 00000000..7fe7163c --- /dev/null +++ b/server/modules/providers/list/cursor/cursor.provider.ts @@ -0,0 +1,10 @@ +import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js'; + +export class CursorProvider extends AbstractProvider { + readonly mcp = new CursorMcpProvider(); + + constructor() { + super('cursor'); + } +} diff --git a/server/modules/providers/list/gemini/gemini-mcp.provider.ts b/server/modules/providers/list/gemini/gemini-mcp.provider.ts new file mode 100644 index 00000000..b86b8f2d --- /dev/null +++ b/server/modules/providers/list/gemini/gemini-mcp.provider.ts @@ -0,0 +1,110 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; +import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; +import { + AppError, + readJsonConfig, + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, + writeJsonConfig, +} from '@/shared/utils.js'; + +export class GeminiMcpProvider extends McpProvider { + constructor() { + super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']); + } + + protected async readScopedServers(scope: McpScope, workspacePath: string): Promise> { + const filePath = scope === 'user' + ? path.join(os.homedir(), '.gemini', 'settings.json') + : path.join(workspacePath, '.gemini', 'settings.json'); + const config = await readJsonConfig(filePath); + return readObjectRecord(config.mcpServers) ?? {}; + } + + protected async writeScopedServers( + scope: McpScope, + workspacePath: string, + servers: Record, + ): Promise { + const filePath = scope === 'user' + ? path.join(os.homedir(), '.gemini', 'settings.json') + : path.join(workspacePath, '.gemini', 'settings.json'); + const config = await readJsonConfig(filePath); + config.mcpServers = servers; + await writeJsonConfig(filePath, config); + } + + protected buildServerConfig(input: UpsertProviderMcpServerInput): Record { + if (input.transport === 'stdio') { + if (!input.command?.trim()) { + throw new AppError('command is required for stdio MCP servers.', { + code: 'MCP_COMMAND_REQUIRED', + statusCode: 400, + }); + } + + return { + command: input.command, + args: input.args ?? [], + env: input.env ?? {}, + cwd: input.cwd, + }; + } + + if (!input.url?.trim()) { + throw new AppError('url is required for http/sse MCP servers.', { + code: 'MCP_URL_REQUIRED', + statusCode: 400, + }); + } + + return { + type: input.transport, + url: input.url, + headers: input.headers ?? {}, + }; + } + + protected normalizeServerConfig( + scope: McpScope, + name: string, + rawConfig: unknown, + ): ProviderMcpServer | null { + if (!rawConfig || typeof rawConfig !== 'object') { + return null; + } + + const config = rawConfig as Record; + if (typeof config.command === 'string') { + return { + provider: 'gemini', + name, + scope, + transport: 'stdio', + command: config.command, + args: readStringArray(config.args), + env: readStringRecord(config.env), + cwd: readOptionalString(config.cwd), + }; + } + + if (typeof config.url === 'string') { + const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http'; + return { + provider: 'gemini', + name, + scope, + transport, + url: config.url, + headers: readStringRecord(config.headers), + }; + } + + return null; + } +} diff --git a/server/modules/providers/list/gemini/gemini.provider.ts b/server/modules/providers/list/gemini/gemini.provider.ts new file mode 100644 index 00000000..69a3510c --- /dev/null +++ b/server/modules/providers/list/gemini/gemini.provider.ts @@ -0,0 +1,10 @@ +import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js'; + +export class GeminiProvider extends AbstractProvider { + readonly mcp = new GeminiMcpProvider(); + + constructor() { + super('gemini'); + } +} diff --git a/server/modules/providers/provider.registry.ts b/server/modules/providers/provider.registry.ts new file mode 100644 index 00000000..4ae3ef78 --- /dev/null +++ b/server/modules/providers/provider.registry.ts @@ -0,0 +1,36 @@ +import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js'; +import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js'; +import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js'; +import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js'; +import type { IProvider } from '@/shared/interfaces.js'; +import type { LLMProvider } from '@/shared/types.js'; +import { AppError } from '@/shared/utils.js'; + +const providers: Record = { + claude: new ClaudeProvider(), + codex: new CodexProvider(), + cursor: new CursorProvider(), + gemini: new GeminiProvider(), +}; + +/** + * Central registry for resolving provider MCP implementations by id. + */ +export const providerRegistry = { + listProviders(): IProvider[] { + return Object.values(providers); + }, + + resolveProvider(provider: string): IProvider { + const key = provider as LLMProvider; + const resolvedProvider = providers[key]; + if (!resolvedProvider) { + throw new AppError(`Unsupported provider "${provider}".`, { + code: 'UNSUPPORTED_PROVIDER', + statusCode: 400, + }); + } + + return resolvedProvider; + }, +}; diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts new file mode 100644 index 00000000..0e376523 --- /dev/null +++ b/server/modules/providers/provider.routes.ts @@ -0,0 +1,236 @@ +import express, { type Request, type Response } from 'express'; + +import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; +import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js'; +import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js'; + +const router = express.Router(); + +const readPathParam = (value: unknown, name: string): string => { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value) && typeof value[0] === 'string') { + return value[0]; + } + + throw new AppError(`${name} path parameter is invalid.`, { + code: 'INVALID_PATH_PARAMETER', + statusCode: 400, + }); +}; + +const normalizeProviderParam = (value: unknown): string => + readPathParam(value, 'provider').trim().toLowerCase(); + +const readOptionalQueryString = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +}; + +const parseMcpScope = (value: unknown): McpScope | undefined => { + if (value === undefined) { + return undefined; + } + + const normalized = readOptionalQueryString(value); + if (!normalized) { + return undefined; + } + + if (normalized === 'user' || normalized === 'local' || normalized === 'project') { + return normalized; + } + + throw new AppError(`Unsupported MCP scope "${normalized}".`, { + code: 'INVALID_MCP_SCOPE', + statusCode: 400, + }); +}; + +const parseMcpTransport = (value: unknown): McpTransport => { + const normalized = readOptionalQueryString(value); + if (!normalized) { + throw new AppError('transport is required.', { + code: 'MCP_TRANSPORT_REQUIRED', + statusCode: 400, + }); + } + + if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') { + return normalized; + } + + throw new AppError(`Unsupported MCP transport "${normalized}".`, { + code: 'INVALID_MCP_TRANSPORT', + statusCode: 400, + }); +}; + +const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => { + if (!payload || typeof payload !== 'object') { + throw new AppError('Request body must be an object.', { + code: 'INVALID_REQUEST_BODY', + statusCode: 400, + }); + } + + const body = payload as Record; + const name = readOptionalQueryString(body.name); + if (!name) { + throw new AppError('name is required.', { + code: 'MCP_NAME_REQUIRED', + statusCode: 400, + }); + } + + const transport = parseMcpTransport(body.transport); + const scope = parseMcpScope(body.scope); + const workspacePath = readOptionalQueryString(body.workspacePath); + + return { + name, + transport, + scope, + workspacePath, + command: readOptionalQueryString(body.command), + args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined, + env: typeof body.env === 'object' && body.env !== null + ? Object.fromEntries( + Object.entries(body.env as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) + : undefined, + cwd: readOptionalQueryString(body.cwd), + url: readOptionalQueryString(body.url), + headers: typeof body.headers === 'object' && body.headers !== null + ? Object.fromEntries( + Object.entries(body.headers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) + : undefined, + envVars: Array.isArray(body.envVars) + ? body.envVars.filter((entry): entry is string => typeof entry === 'string') + : undefined, + bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar), + envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null + ? Object.fromEntries( + Object.entries(body.envHttpHeaders as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) + : undefined, + }; +}; + +const parseProvider = (value: unknown): LLMProvider => { + const normalized = normalizeProviderParam(value); + if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') { + return normalized; + } + + throw new AppError(`Unsupported provider "${normalized}".`, { + code: 'UNSUPPORTED_PROVIDER', + statusCode: 400, + }); +}; + +router.get( + '/providers/:provider/mcp/servers', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + const workspacePath = readOptionalQueryString(req.query.workspacePath); + const scope = parseMcpScope(req.query.scope); + + if (scope) { + const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath }); + res.json(createApiSuccessResponse({ provider, scope, servers })); + return; + } + + const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath }); + res.json(createApiSuccessResponse({ provider, scopes: groupedServers })); + }), +); + +router.post( + '/providers/:provider/mcp/servers', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + const payload = parseMcpUpsertPayload(req.body); + const server = await providerMcpService.upsertProviderMcpServer(provider, payload); + res.status(201).json(createApiSuccessResponse({ server })); + }), +); + +router.put( + '/providers/:provider/mcp/servers/:name', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + const payload = parseMcpUpsertPayload({ + ...((req.body && typeof req.body === 'object') ? req.body as Record : {}), + name: readPathParam(req.params.name, 'name'), + }); + const server = await providerMcpService.upsertProviderMcpServer(provider, payload); + res.json(createApiSuccessResponse({ server })); + }), +); + +router.delete( + '/providers/:provider/mcp/servers/:name', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + const scope = parseMcpScope(req.query.scope); + const workspacePath = readOptionalQueryString(req.query.workspacePath); + const result = await providerMcpService.removeProviderMcpServer(provider, { + name: readPathParam(req.params.name, 'name'), + scope, + workspacePath, + }); + res.json(createApiSuccessResponse(result)); + }), +); + +router.post( + '/providers/:provider/mcp/servers/:name/run', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + const body = (req.body as Record | undefined) ?? {}; + const scope = parseMcpScope(body.scope ?? req.query.scope); + const workspacePath = readOptionalQueryString(body.workspacePath ?? req.query.workspacePath); + const result = await providerMcpService.runProviderMcpServer(provider, { + name: readPathParam(req.params.name, 'name'), + scope, + workspacePath, + }); + res.json(createApiSuccessResponse(result)); + }), +); + +router.post( + '/mcp/servers/global', + asyncHandler(async (req: Request, res: Response) => { + const payload = parseMcpUpsertPayload(req.body); + if (payload.scope === 'local') { + throw new AppError('Global MCP add supports only "user" or "project" scopes.', { + code: 'INVALID_GLOBAL_MCP_SCOPE', + statusCode: 400, + }); + } + + const results = await providerMcpService.addMcpServerToAllProviders({ + ...payload, + scope: payload.scope === 'user' ? 'user' : 'project', + }); + res.status(201).json(createApiSuccessResponse({ results })); + }), +); + +export default router; diff --git a/server/modules/providers/services/mcp.service.ts b/server/modules/providers/services/mcp.service.ts new file mode 100644 index 00000000..9ab31bad --- /dev/null +++ b/server/modules/providers/services/mcp.service.ts @@ -0,0 +1,102 @@ +import { providerRegistry } from '@/modules/providers/provider.registry.js'; +import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; +import { AppError } from '@/shared/utils.js'; + + +export const providerMcpService = { + /** + * Lists MCP servers for one provider grouped by supported scopes. + */ + async listProviderMcpServers( + providerName: string, + options?: { workspacePath?: string }, + ): Promise> { + const provider = providerRegistry.resolveProvider(providerName); + return provider.mcp.listServers(options); + }, + + /** + * Lists MCP servers for one provider scope. + */ + async listProviderMcpServersForScope( + providerName: string, + scope: McpScope, + options?: { workspacePath?: string }, + ): Promise { + const provider = providerRegistry.resolveProvider(providerName); + return provider.mcp.listServersForScope(scope, options); + }, + + /** + * Adds or updates one provider MCP server. + */ + async upsertProviderMcpServer( + providerName: string, + input: UpsertProviderMcpServerInput, + ): Promise { + const provider = providerRegistry.resolveProvider(providerName); + return provider.mcp.upsertServer(input); + }, + + /** + * Removes one provider MCP server. + */ + async removeProviderMcpServer( + providerName: string, + input: { name: string; scope?: McpScope; workspacePath?: string }, + ): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> { + const provider = providerRegistry.resolveProvider(providerName); + return provider.mcp.removeServer(input); + }, + + /** + * Runs one provider MCP server probe. + */ + async runProviderMcpServer( + providerName: string, + input: { name: string; scope?: McpScope; workspacePath?: string }, + ): Promise<{ + provider: LLMProvider; + name: string; + scope: McpScope; + transport: 'stdio' | 'http' | 'sse'; + reachable: boolean; + statusCode?: number; + error?: string; + }> { + const provider = providerRegistry.resolveProvider(providerName); + return provider.mcp.runServer(input); + }, + + /** + * Adds one HTTP/stdio MCP server to every provider. + */ + async addMcpServerToAllProviders( + input: Omit & { scope?: Exclude }, + ): Promise> { + if (input.transport !== 'stdio' && input.transport !== 'http') { + throw new AppError('Global MCP add supports only "stdio" and "http".', { + code: 'INVALID_GLOBAL_MCP_TRANSPORT', + statusCode: 400, + }); + } + + const scope = input.scope ?? 'project'; + const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = []; + const providers = providerRegistry.listProviders(); + for (const provider of providers) { + try { + await provider.mcp.upsertServer({ ...input, scope }); + results.push({ provider: provider.id, created: true }); + } catch (error) { + results.push({ + provider: provider.id, + created: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + return results; + }, +}; diff --git a/server/modules/providers/shared/base/abstract.provider.ts b/server/modules/providers/shared/base/abstract.provider.ts new file mode 100644 index 00000000..2cd24591 --- /dev/null +++ b/server/modules/providers/shared/base/abstract.provider.ts @@ -0,0 +1,14 @@ +import type { IProvider, IProviderMcpRuntime } from '@/shared/interfaces.js'; +import type { LLMProvider } from '@/shared/types.js'; + +/** + * Shared MCP-only provider base. + */ +export abstract class AbstractProvider implements IProvider { + readonly id: LLMProvider; + abstract readonly mcp: IProviderMcpRuntime; + + protected constructor(id: LLMProvider) { + this.id = id; + } +} diff --git a/server/modules/providers/shared/mcp/mcp.provider.ts b/server/modules/providers/shared/mcp/mcp.provider.ts new file mode 100644 index 00000000..dc2808d9 --- /dev/null +++ b/server/modules/providers/shared/mcp/mcp.provider.ts @@ -0,0 +1,279 @@ +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> { + const grouped: Record = { + 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 { + 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 { + 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>; + + protected abstract writeScopedServers( + scope: McpScope, + workspacePath: string, + servers: Record, + ): Promise; + + protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record; + + 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, + }); + } + } +} diff --git a/server/modules/providers/tests/mcp.test.ts b/server/modules/providers/tests/mcp.test.ts new file mode 100644 index 00000000..30e9ab68 --- /dev/null +++ b/server/modules/providers/tests/mcp.test.ts @@ -0,0 +1,350 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import TOML from '@iarna/toml'; + +import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; +import { AppError } from '@/shared/utils.js'; + +const patchHomeDir = (nextHomeDir: string) => { + const original = os.homedir; + (os as any).homedir = () => nextHomeDir; + return () => { + (os as any).homedir = original; + }; +}; + +const readJson = async (filePath: string): Promise> => { + const content = await fs.readFile(filePath, 'utf8'); + return JSON.parse(content) as Record; +}; + +/** + * This test covers Claude MCP support for all scopes (user/local/project) and all transports (stdio/http/sse), + * including add, update/list, and remove operations. + */ +test('providerMcpService handles claude MCP scopes/transports with file-backed persistence', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-claude-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await fs.mkdir(workspacePath, { recursive: true }); + + const restoreHomeDir = patchHomeDir(tempRoot); + try { + await providerMcpService.upsertProviderMcpServer('claude', { + name: 'claude-user-stdio', + scope: 'user', + transport: 'stdio', + command: 'npx', + args: ['-y', 'my-server'], + env: { API_KEY: 'secret' }, + }); + + await providerMcpService.upsertProviderMcpServer('claude', { + name: 'claude-local-http', + scope: 'local', + transport: 'http', + url: 'https://example.com/mcp', + headers: { Authorization: 'Bearer token' }, + workspacePath, + }); + + await providerMcpService.upsertProviderMcpServer('claude', { + name: 'claude-project-sse', + scope: 'project', + transport: 'sse', + url: 'https://example.com/sse', + headers: { 'X-API-Key': 'abc' }, + workspacePath, + }); + + const grouped = await providerMcpService.listProviderMcpServers('claude', { workspacePath }); + assert.ok(grouped.user.some((server) => server.name === 'claude-user-stdio' && server.transport === 'stdio')); + assert.ok(grouped.local.some((server) => server.name === 'claude-local-http' && server.transport === 'http')); + assert.ok(grouped.project.some((server) => server.name === 'claude-project-sse' && server.transport === 'sse')); + + // update behavior is the same upsert route with same name + await providerMcpService.upsertProviderMcpServer('claude', { + name: 'claude-project-sse', + scope: 'project', + transport: 'sse', + url: 'https://example.com/sse-updated', + headers: { 'X-API-Key': 'updated' }, + workspacePath, + }); + + const projectConfig = await readJson(path.join(workspacePath, '.mcp.json')); + const projectServers = projectConfig.mcpServers as Record; + const projectServer = projectServers['claude-project-sse'] as Record; + assert.equal(projectServer.url, 'https://example.com/sse-updated'); + + const removeResult = await providerMcpService.removeProviderMcpServer('claude', { + name: 'claude-local-http', + scope: 'local', + workspacePath, + }); + assert.equal(removeResult.removed, true); + } finally { + restoreHomeDir(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +/** + * This test covers Codex MCP support for user/project scopes, stdio/http formats, + * and validation for unsupported scope/transport combinations. + */ +test('providerMcpService handles codex MCP TOML config and capability validation', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-codex-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await fs.mkdir(workspacePath, { recursive: true }); + + const restoreHomeDir = patchHomeDir(tempRoot); + try { + await providerMcpService.upsertProviderMcpServer('codex', { + name: 'codex-user-stdio', + scope: 'user', + transport: 'stdio', + command: 'python', + args: ['server.py'], + env: { API_KEY: 'x' }, + envVars: ['API_KEY'], + cwd: '/tmp', + }); + + await providerMcpService.upsertProviderMcpServer('codex', { + name: 'codex-project-http', + scope: 'project', + transport: 'http', + url: 'https://codex.example.com/mcp', + headers: { 'X-Custom-Header': 'value' }, + envHttpHeaders: { 'X-API-Key': 'MY_API_KEY_ENV' }, + bearerTokenEnvVar: 'MY_API_TOKEN', + workspacePath, + }); + + const userTomlPath = path.join(tempRoot, '.codex', 'config.toml'); + const userConfig = TOML.parse(await fs.readFile(userTomlPath, 'utf8')) as Record; + const userServers = userConfig.mcp_servers as Record; + const userStdio = userServers['codex-user-stdio'] as Record; + assert.equal(userStdio.command, 'python'); + + const projectTomlPath = path.join(workspacePath, '.codex', 'config.toml'); + const projectConfig = TOML.parse(await fs.readFile(projectTomlPath, 'utf8')) as Record; + const projectServers = projectConfig.mcp_servers as Record; + const projectHttp = projectServers['codex-project-http'] as Record; + assert.equal(projectHttp.url, 'https://codex.example.com/mcp'); + + await assert.rejects( + providerMcpService.upsertProviderMcpServer('codex', { + name: 'codex-local', + scope: 'local', + transport: 'stdio', + command: 'node', + }), + (error: unknown) => + error instanceof AppError && + error.code === 'MCP_SCOPE_NOT_SUPPORTED' && + error.statusCode === 400, + ); + + await assert.rejects( + providerMcpService.upsertProviderMcpServer('codex', { + name: 'codex-sse', + scope: 'project', + transport: 'sse', + url: 'https://example.com/sse', + workspacePath, + }), + (error: unknown) => + error instanceof AppError && + error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' && + error.statusCode === 400, + ); + } finally { + restoreHomeDir(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +/** + * This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence. + */ +test('providerMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await fs.mkdir(workspacePath, { recursive: true }); + + const restoreHomeDir = patchHomeDir(tempRoot); + try { + await providerMcpService.upsertProviderMcpServer('gemini', { + name: 'gemini-stdio', + scope: 'user', + transport: 'stdio', + command: 'node', + args: ['server.js'], + env: { TOKEN: '$TOKEN' }, + cwd: './server', + }); + + await providerMcpService.upsertProviderMcpServer('gemini', { + name: 'gemini-http', + scope: 'project', + transport: 'http', + url: 'https://gemini.example.com/mcp', + headers: { Authorization: 'Bearer token' }, + workspacePath, + }); + + await providerMcpService.upsertProviderMcpServer('cursor', { + name: 'cursor-stdio', + scope: 'project', + transport: 'stdio', + command: 'npx', + args: ['-y', 'mcp-server'], + env: { API_KEY: 'value' }, + workspacePath, + }); + + await providerMcpService.upsertProviderMcpServer('cursor', { + name: 'cursor-http', + scope: 'user', + transport: 'http', + url: 'http://localhost:3333/mcp', + headers: { API_KEY: 'value' }, + }); + + const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json')); + const geminiUserServer = (geminiUserConfig.mcpServers as Record)['gemini-stdio'] as Record; + assert.equal(geminiUserServer.command, 'node'); + assert.equal(geminiUserServer.type, undefined); + + const geminiProjectConfig = await readJson(path.join(workspacePath, '.gemini', 'settings.json')); + const geminiProjectServer = (geminiProjectConfig.mcpServers as Record)['gemini-http'] as Record; + assert.equal(geminiProjectServer.type, 'http'); + + const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json')); + const cursorHttpServer = (cursorUserConfig.mcpServers as Record)['cursor-http'] as Record; + assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp'); + assert.equal(cursorHttpServer.type, undefined); + } finally { + restoreHomeDir(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +/** + * This test covers the global MCP adder requirement: only http/stdio are allowed and + * one payload is written to all providers. + */ +test('providerMcpService global adder writes to all providers and rejects unsupported transports', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-global-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await fs.mkdir(workspacePath, { recursive: true }); + + const restoreHomeDir = patchHomeDir(tempRoot); + try { + const globalResult = await providerMcpService.addMcpServerToAllProviders({ + name: 'global-http', + scope: 'project', + transport: 'http', + url: 'https://global.example.com/mcp', + workspacePath, + }); + + assert.equal(globalResult.length, 4); + assert.ok(globalResult.every((entry) => entry.created === true)); + + const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); + assert.ok((claudeProject.mcpServers as Record)['global-http']); + + const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record; + assert.ok((codexProject.mcp_servers as Record)['global-http']); + + const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json')); + assert.ok((geminiProject.mcpServers as Record)['global-http']); + + const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json')); + assert.ok((cursorProject.mcpServers as Record)['global-http']); + + await assert.rejects( + providerMcpService.addMcpServerToAllProviders({ + name: 'global-sse', + scope: 'project', + transport: 'sse', + url: 'https://example.com/sse', + workspacePath, + }), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_GLOBAL_MCP_TRANSPORT' && + error.statusCode === 400, + ); + } finally { + restoreHomeDir(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +/** + * This test covers "run" behavior for both stdio and http MCP servers. + */ +test('providerMcpService runProviderServer probes stdio and http MCP servers', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-run-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await fs.mkdir(workspacePath, { recursive: true }); + + const restoreHomeDir = patchHomeDir(tempRoot); + const server = http.createServer((_req, res) => { + res.statusCode = 200; + res.end('ok'); + }); + + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/mcp`; + + await providerMcpService.upsertProviderMcpServer('gemini', { + name: 'probe-http', + scope: 'project', + transport: 'http', + url, + workspacePath, + }); + + await providerMcpService.upsertProviderMcpServer('cursor', { + name: 'probe-stdio', + scope: 'project', + transport: 'stdio', + command: process.execPath, + args: ['-e', 'process.exit(0)'], + workspacePath, + }); + + const httpProbe = await providerMcpService.runProviderMcpServer('gemini', { + name: 'probe-http', + scope: 'project', + workspacePath, + }); + assert.equal(httpProbe.reachable, true); + assert.equal(httpProbe.transport, 'http'); + + const stdioProbe = await providerMcpService.runProviderMcpServer('cursor', { + name: 'probe-stdio', + scope: 'project', + workspacePath, + }); + assert.equal(stdioProbe.reachable, true); + assert.equal(stdioProbe.transport, 'stdio'); + } finally { + server.close(); + restoreHomeDir(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts new file mode 100644 index 00000000..697ac240 --- /dev/null +++ b/server/shared/interfaces.ts @@ -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>; + listServersForScope(scope: McpScope, options?: { workspacePath?: string }): Promise; + upsertServer(input: UpsertProviderMcpServerInput): Promise; + 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; +} \ No newline at end of file diff --git a/server/shared/types.ts b/server/shared/types.ts new file mode 100644 index 00000000..1a3859fe --- /dev/null +++ b/server/shared/types.ts @@ -0,0 +1,70 @@ +// -------------- HTTP API response shapes for the server, shared across modules -------------- + +export type ApiSuccessShape = { + 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; + cwd?: string; + url?: string; + headers?: Record; + envVars?: string[]; + bearerTokenEnvVar?: string; + envHttpHeaders?: Record; +}; + +/** + * 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; + cwd?: string; + url?: string; + headers?: Record; + envVars?: string[]; + bearerTokenEnvVar?: string; + envHttpHeaders?: Record; +}; diff --git a/server/shared/utils.ts b/server/shared/utils.ts new file mode 100644 index 00000000..3b6bd8dc --- /dev/null +++ b/server/shared/utils.ts @@ -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( + data: TData, +): ApiSuccessShape { + 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 +): 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 | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + return value as Record; +}; + +/** + * 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 | undefined => { + const record = readObjectRecord(value); + if (!record) { + return undefined; + } + + const normalized: Record = {}; + 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> => { + try { + const content = await readFile(filePath, 'utf8'); + const parsed = JSON.parse(content) as Record; + 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): Promise => { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); +}; + +// ------------------------------------------------------------------------------------------- +