mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-08 14:45:50 +08: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:
@@ -148,9 +148,17 @@ export default tseslint.config(
|
|||||||
],
|
],
|
||||||
"boundaries/elements": [
|
"boundaries/elements": [
|
||||||
{
|
{
|
||||||
type: "backend-shared-types", // shared backend type contract that modules may consume without creating runtime coupling
|
type: "backend-shared-type-contract", // shared backend type/interface contracts that modules may consume without creating runtime coupling
|
||||||
pattern: ["server/shared/types.{js,ts}"], // support the current shared types path
|
pattern: [
|
||||||
mode: "file", // treat the types file itself as the boundary element instead of the whole folder
|
"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
|
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
|
checkInternals: false, // do not apply these cross-module rules to imports inside the same module
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
from: { type: "backend-module" }, // modules may depend on the shared types contract only as erased type-only imports
|
from: { type: "backend-module" }, // modules may depend on shared type/interface contracts only as erased type-only imports
|
||||||
to: { type: "backend-shared-types" },
|
to: { type: "backend-shared-type-contract" },
|
||||||
disallow: {
|
disallow: {
|
||||||
dependency: { kind: ["value", "typeof"] },
|
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:
|
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
|
to: { type: "backend-module" }, // when importing anything that belongs to another backend module
|
||||||
|
|||||||
100
package-lock.json
generated
100
package-lock.json
generated
@@ -76,6 +76,8 @@
|
|||||||
"@commitlint/config-conventional": "^20.4.3",
|
"@commitlint/config-conventional": "^20.4.3",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@release-it/conventional-changelog": "^10.0.5",
|
"@release-it/conventional-changelog": "^10.0.5",
|
||||||
|
"@types/cross-spawn": "^6.0.6",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
@@ -3752,6 +3754,37 @@
|
|||||||
"@babel/types": "^7.20.7"
|
"@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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -3776,6 +3809,31 @@
|
|||||||
"@types/estree": "*"
|
"@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": {
|
"node_modules/@types/hast": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||||
@@ -3785,6 +3843,13 @@
|
|||||||
"@types/unist": "*"
|
"@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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -3843,6 +3908,20 @@
|
|||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.23",
|
"version": "18.3.23",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||||
@@ -3863,6 +3942,27 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@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": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
|||||||
@@ -128,6 +128,8 @@
|
|||||||
"@commitlint/config-conventional": "^20.4.3",
|
"@commitlint/config-conventional": "^20.4.3",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@release-it/conventional-changelog": "^10.0.5",
|
"@release-it/conventional-changelog": "^10.0.5",
|
||||||
|
"@types/cross-spawn": "^6.0.6",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
||||||
|
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
|
||||||
const __dirname = getModuleDir(import.meta.url);
|
const __dirname = getModuleDir(import.meta.url);
|
||||||
// The server source runs from /server, while the compiled output runs from /dist-server/server.
|
// 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.
|
// 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
|
// Helper function to convert permissions to rwx format
|
||||||
function permToRwx(perm) {
|
function permToRwx(perm) {
|
||||||
const r = perm & 4 ? 'r' : '-';
|
const r = perm & 4 ? 'r' : '-';
|
||||||
|
|||||||
135
server/modules/providers/list/claude/claude-mcp.provider.ts
Normal file
135
server/modules/providers/list/claude/claude-mcp.provider.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
server/modules/providers/list/claude/claude.provider.ts
Normal file
10
server/modules/providers/list/claude/claude.provider.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
135
server/modules/providers/list/codex/codex-mcp.provider.ts
Normal file
135
server/modules/providers/list/codex/codex-mcp.provider.ts
Normal file
@@ -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<Record<string, unknown>> => {
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath, 'utf8');
|
||||||
|
const parsed = TOML.parse(content) as Record<string, unknown>;
|
||||||
|
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<string, unknown>): Promise<void> => {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
server/modules/providers/list/codex/codex.provider.ts
Normal file
10
server/modules/providers/list/codex/codex.provider.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
108
server/modules/providers/list/cursor/cursor-mcp.provider.ts
Normal file
108
server/modules/providers/list/cursor/cursor-mcp.provider.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
server/modules/providers/list/cursor/cursor.provider.ts
Normal file
10
server/modules/providers/list/cursor/cursor.provider.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
110
server/modules/providers/list/gemini/gemini-mcp.provider.ts
Normal file
110
server/modules/providers/list/gemini/gemini-mcp.provider.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
server/modules/providers/list/gemini/gemini.provider.ts
Normal file
10
server/modules/providers/list/gemini/gemini.provider.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
server/modules/providers/provider.registry.ts
Normal file
36
server/modules/providers/provider.registry.ts
Normal file
@@ -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<LLMProvider, IProvider> = {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
236
server/modules/providers/provider.routes.ts
Normal file
236
server/modules/providers/provider.routes.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown> : {}),
|
||||||
|
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<string, unknown> | 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;
|
||||||
102
server/modules/providers/services/mcp.service.ts
Normal file
102
server/modules/providers/services/mcp.service.ts
Normal file
@@ -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<Record<McpScope, ProviderMcpServer[]>> {
|
||||||
|
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<ProviderMcpServer[]> {
|
||||||
|
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<ProviderMcpServer> {
|
||||||
|
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<UpsertProviderMcpServerInput, 'scope'> & { scope?: Exclude<McpScope, 'local'> },
|
||||||
|
): Promise<Array<{ provider: LLMProvider; created: boolean; error?: string }>> {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
14
server/modules/providers/shared/base/abstract.provider.ts
Normal file
14
server/modules/providers/shared/base/abstract.provider.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
279
server/modules/providers/shared/mcp/mcp.provider.ts
Normal file
279
server/modules/providers/shared/mcp/mcp.provider.ts
Normal file
@@ -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<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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
350
server/modules/providers/tests/mcp.test.ts
Normal file
350
server/modules/providers/tests/mcp.test.ts
Normal file
@@ -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<Record<string, unknown>> => {
|
||||||
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
|
return JSON.parse(content) as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>;
|
||||||
|
const projectServer = projectServers['claude-project-sse'] as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
const userServers = userConfig.mcp_servers as Record<string, unknown>;
|
||||||
|
const userStdio = userServers['codex-user-stdio'] as Record<string, unknown>;
|
||||||
|
assert.equal(userStdio.command, 'python');
|
||||||
|
|
||||||
|
const projectTomlPath = path.join(workspacePath, '.codex', 'config.toml');
|
||||||
|
const projectConfig = TOML.parse(await fs.readFile(projectTomlPath, 'utf8')) as Record<string, unknown>;
|
||||||
|
const projectServers = projectConfig.mcp_servers as Record<string, unknown>;
|
||||||
|
const projectHttp = projectServers['codex-project-http'] as Record<string, unknown>;
|
||||||
|
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<string, unknown>)['gemini-stdio'] as Record<string, unknown>;
|
||||||
|
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<string, unknown>)['gemini-http'] as Record<string, unknown>;
|
||||||
|
assert.equal(geminiProjectServer.type, 'http');
|
||||||
|
|
||||||
|
const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json'));
|
||||||
|
const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>;
|
||||||
|
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<string, unknown>)['global-http']);
|
||||||
|
|
||||||
|
const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record<string, unknown>;
|
||||||
|
assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']);
|
||||||
|
|
||||||
|
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||||
|
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||||
|
|
||||||
|
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||||
|
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['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<void>((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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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