From b09ce9dc6052102d0ce29027bc4237030456c99a Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Tue, 7 Apr 2026 13:24:01 +0300 Subject: [PATCH] refactor: add mcp and skills to llmService - Deleted the llmSkillsService implementation and its associated methods for listing provider skills. - Updated tests to use llmService instead of llmMcpService and llmSkillsService for handling MCP and skills functionalities. - Adjusted test cases to reflect the new service structure while maintaining existing functionality. --- server/src/modules/llm/llm.routes.ts | 32 +- .../llm/providers/abstract.provider.ts | 4 + .../modules/llm/providers/claude.provider.ts | 7 + .../modules/llm/providers/codex.provider.ts | 13 +- .../modules/llm/providers/cursor.provider.ts | 12 +- .../modules/llm/providers/gemini.provider.ts | 12 +- .../llm/providers/provider.interface.ts | 89 ++ .../runtimes/base-provider-mcp.runtime.ts | 236 +++++ .../providers/runtimes/claude-mcp.runtime.ts | 154 ++++ .../runtimes/claude-skills.runtime.ts | 133 +++ .../providers/runtimes/codex-mcp.runtime.ts | 133 +++ .../runtimes/codex-skills.runtime.ts | 47 + .../providers/runtimes/cursor-mcp.runtime.ts | 127 +++ .../runtimes/cursor-skills.runtime.ts | 40 + .../providers/runtimes/gemini-mcp.runtime.ts | 129 +++ .../runtimes/gemini-skills.runtime.ts | 41 + .../providers/runtimes/mcp-runtime.utils.ts | 207 +++++ .../runtimes/skills-runtime.utils.ts | 154 ++++ .../src/modules/llm/services/llm.service.ts | 111 +++ .../src/modules/llm/services/mcp.service.ts | 817 ------------------ .../modules/llm/services/skills.service.ts | 396 --------- .../modules/llm/tests/llm-unifier.mcp.test.ts | 54 +- .../llm/tests/llm-unifier.skills.test.ts | 18 +- 23 files changed, 1693 insertions(+), 1273 deletions(-) create mode 100644 server/src/modules/llm/providers/runtimes/base-provider-mcp.runtime.ts create mode 100644 server/src/modules/llm/providers/runtimes/claude-mcp.runtime.ts create mode 100644 server/src/modules/llm/providers/runtimes/claude-skills.runtime.ts create mode 100644 server/src/modules/llm/providers/runtimes/codex-mcp.runtime.ts create mode 100644 server/src/modules/llm/providers/runtimes/codex-skills.runtime.ts create mode 100644 server/src/modules/llm/providers/runtimes/cursor-mcp.runtime.ts create mode 100644 server/src/modules/llm/providers/runtimes/cursor-skills.runtime.ts create mode 100644 server/src/modules/llm/providers/runtimes/gemini-mcp.runtime.ts create mode 100644 server/src/modules/llm/providers/runtimes/gemini-skills.runtime.ts create mode 100644 server/src/modules/llm/providers/runtimes/mcp-runtime.utils.ts create mode 100644 server/src/modules/llm/providers/runtimes/skills-runtime.utils.ts delete mode 100644 server/src/modules/llm/services/mcp.service.ts delete mode 100644 server/src/modules/llm/services/skills.service.ts diff --git a/server/src/modules/llm/llm.routes.ts b/server/src/modules/llm/llm.routes.ts index 1797df57..4d9be07c 100644 --- a/server/src/modules/llm/llm.routes.ts +++ b/server/src/modules/llm/llm.routes.ts @@ -1,14 +1,11 @@ import express, { type NextFunction, type Request, type Response } from 'express'; -import path from 'node:path'; import { asyncHandler } from '@/shared/http/async-handler.js'; import { AppError } from '@/shared/utils/app-error.js'; import { createApiErrorResponse, createApiSuccessResponse } from '@/shared/http/api-response.js'; import { llmService } from '@/modules/llm/services/llm.service.js'; import { llmSessionsService } from '@/modules/llm/services/sessions.service.js'; -import type { McpScope, McpTransport, UpsertMcpServerInput } from '@/modules/llm/services/mcp.service.js'; -import { llmMcpService } from '@/modules/llm/services/mcp.service.js'; -import { llmSkillsService } from '@/modules/llm/services/skills.service.js'; +import type { McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/modules/llm/providers/provider.interface.js'; import { llmMessagesUnifier } from '@/modules/llm/services/messages-unifier.service.js'; import type { LLMProvider } from '@/shared/types/app.js'; import { logger } from '@/shared/utils/logger.js'; @@ -126,7 +123,7 @@ const parseMcpTransport = (value: unknown): McpTransport => { /** * Parses and validates MCP upsert payload. */ -const parseMcpUpsertPayload = (payload: unknown): UpsertMcpServerInput => { +const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => { if (!payload || typeof payload !== 'object') { throw new AppError('Request body must be an object.', { code: 'INVALID_REQUEST_BODY', @@ -307,16 +304,12 @@ router.get( const scope = parseMcpScope(req.query.scope); if (scope) { - const servers = await llmMcpService.listProviderServersForScope( - provider, - scope, - path.resolve(workspacePath ?? process.cwd()), - ); + const servers = await llmService.listProviderMcpServersForScope(provider, scope, { workspacePath }); res.json(createApiSuccessResponse({ provider, scope, servers })); return; } - const groupedServers = await llmMcpService.listProviderServers(provider, { workspacePath }); + const groupedServers = await llmService.listProviderMcpServers(provider, { workspacePath }); res.json(createApiSuccessResponse({ provider, scopes: groupedServers })); }), ); @@ -329,7 +322,7 @@ router.post( asyncHandler(async (req: Request, res: Response) => { const provider = parseProvider(req.params.provider); const payload = parseMcpUpsertPayload(req.body); - const server = await llmMcpService.upsertProviderServer(provider, payload); + const server = await llmService.upsertProviderMcpServer(provider, payload); res.status(201).json(createApiSuccessResponse({ server })); }), ); @@ -345,7 +338,7 @@ router.put( ...((req.body && typeof req.body === 'object') ? req.body as Record : {}), name: readPathParam(req.params.name, 'name'), }); - const server = await llmMcpService.upsertProviderServer(provider, payload); + const server = await llmService.upsertProviderMcpServer(provider, payload); res.json(createApiSuccessResponse({ server })); }), ); @@ -359,7 +352,7 @@ router.delete( const provider = parseProvider(req.params.provider); const scope = parseMcpScope(req.query.scope); const workspacePath = readOptionalQueryString(req.query.workspacePath); - const result = await llmMcpService.removeProviderServer(provider, { + const result = await llmService.removeProviderMcpServer(provider, { name: readPathParam(req.params.name, 'name'), scope, workspacePath, @@ -378,8 +371,7 @@ router.post( 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 llmMcpService.runProviderServer({ - provider, + const result = await llmService.runProviderMcpServer(provider, { name: readPathParam(req.params.name, 'name'), scope, workspacePath, @@ -401,7 +393,7 @@ router.post( statusCode: 400, }); } - const results = await llmMcpService.addServerToAllProviders({ + const results = await llmService.addMcpServerToAllProviders({ ...payload, scope: payload.scope === 'user' ? 'user' : 'project', }); @@ -417,7 +409,7 @@ router.get( asyncHandler(async (req: Request, res: Response) => { const provider = parseProvider(req.params.provider); const workspacePath = readOptionalQueryString(req.query.workspacePath); - const skills = await llmSkillsService.listProviderSkills(provider, { workspacePath }); + const skills = await llmService.listProviderSkills(provider, { workspacePath }); res.json(createApiSuccessResponse({ provider, skills })); }), ); @@ -432,7 +424,7 @@ router.get( const workspacePath = readOptionalQueryString(req.query.workspacePath); if (providerQuery) { const provider = parseProvider(providerQuery); - const skills = await llmSkillsService.listProviderSkills(provider, { workspacePath }); + const skills = await llmService.listProviderSkills(provider, { workspacePath }); res.json(createApiSuccessResponse({ provider, skills })); return; } @@ -442,7 +434,7 @@ router.get( await Promise.all( providers.map(async (provider) => ([ provider, - await llmSkillsService.listProviderSkills(provider, { workspacePath }), + await llmService.listProviderSkills(provider, { workspacePath }), ])), ), ); diff --git a/server/src/modules/llm/providers/abstract.provider.ts b/server/src/modules/llm/providers/abstract.provider.ts index b8023cc5..0d8d659a 100644 --- a/server/src/modules/llm/providers/abstract.provider.ts +++ b/server/src/modules/llm/providers/abstract.provider.ts @@ -1,5 +1,7 @@ import type { IProvider, + IProviderMcpRuntime, + IProviderSkillsRuntime, MutableProviderSession, ProviderCapabilities, ProviderExecutionFamily, @@ -19,6 +21,8 @@ export abstract class AbstractProvider implements IProvider { readonly id: LLMProvider; readonly family: ProviderExecutionFamily; readonly capabilities: ProviderCapabilities; + abstract readonly mcp: IProviderMcpRuntime; + abstract readonly skills: IProviderSkillsRuntime; protected readonly sessions = new Map(); diff --git a/server/src/modules/llm/providers/claude.provider.ts b/server/src/modules/llm/providers/claude.provider.ts index 5aac5816..b2135ae8 100644 --- a/server/src/modules/llm/providers/claude.provider.ts +++ b/server/src/modules/llm/providers/claude.provider.ts @@ -9,11 +9,15 @@ import { readFile } from 'node:fs/promises'; import { BaseSdkProvider } from '@/modules/llm/providers/base-sdk.provider.js'; import type { + IProviderMcpRuntime, + IProviderSkillsRuntime, ProviderModel, ProviderSessionEvent, RuntimePermissionMode, StartSessionInput, } from '@/modules/llm/providers/provider.interface.js'; +import { ClaudeMcpRuntime } from '@/modules/llm/providers/runtimes/claude-mcp.runtime.js'; +import { ClaudeSkillsRuntime } from '@/modules/llm/providers/runtimes/claude-skills.runtime.js'; type ClaudeExecutionInput = StartSessionInput & { sessionId: string; @@ -69,6 +73,9 @@ const readString = (value: unknown): string | undefined => { * Claude SDK provider implementation. */ export class ClaudeProvider extends BaseSdkProvider { + readonly mcp: IProviderMcpRuntime = new ClaudeMcpRuntime(); + readonly skills: IProviderSkillsRuntime = new ClaudeSkillsRuntime(); + constructor() { super('claude', { supportsRuntimePermissionRequests: true, diff --git a/server/src/modules/llm/providers/codex.provider.ts b/server/src/modules/llm/providers/codex.provider.ts index f46c76e6..e2528205 100644 --- a/server/src/modules/llm/providers/codex.provider.ts +++ b/server/src/modules/llm/providers/codex.provider.ts @@ -3,7 +3,15 @@ import path from 'node:path'; import { readFile } from 'node:fs/promises'; import { BaseSdkProvider } from '@/modules/llm/providers/base-sdk.provider.js'; -import type { ProviderModel, ProviderSessionEvent, StartSessionInput } from '@/modules/llm/providers/provider.interface.js'; +import type { + IProviderMcpRuntime, + IProviderSkillsRuntime, + ProviderModel, + ProviderSessionEvent, + StartSessionInput, +} from '@/modules/llm/providers/provider.interface.js'; +import { CodexMcpRuntime } from '@/modules/llm/providers/runtimes/codex-mcp.runtime.js'; +import { CodexSkillsRuntime } from '@/modules/llm/providers/runtimes/codex-skills.runtime.js'; import { AppError } from '@/shared/utils/app-error.js'; type CodexExecutionInput = StartSessionInput & { @@ -57,6 +65,9 @@ type CodexSdkModule = { * Codex SDK provider implementation. */ export class CodexProvider extends BaseSdkProvider { + readonly mcp: IProviderMcpRuntime = new CodexMcpRuntime(); + readonly skills: IProviderSkillsRuntime = new CodexSkillsRuntime(); + private codexClientPromise: Promise | null = null; constructor() { diff --git a/server/src/modules/llm/providers/cursor.provider.ts b/server/src/modules/llm/providers/cursor.provider.ts index 7d931277..e91123d3 100644 --- a/server/src/modules/llm/providers/cursor.provider.ts +++ b/server/src/modules/llm/providers/cursor.provider.ts @@ -1,5 +1,12 @@ import { BaseCliProvider } from '@/modules/llm/providers/base-cli.provider.js'; -import type { ProviderModel, StartSessionInput } from '@/modules/llm/providers/provider.interface.js'; +import type { + IProviderMcpRuntime, + IProviderSkillsRuntime, + ProviderModel, + StartSessionInput, +} from '@/modules/llm/providers/provider.interface.js'; +import { CursorMcpRuntime } from '@/modules/llm/providers/runtimes/cursor-mcp.runtime.js'; +import { CursorSkillsRuntime } from '@/modules/llm/providers/runtimes/cursor-skills.runtime.js'; type CursorExecutionInput = StartSessionInput & { sessionId: string; @@ -14,6 +21,9 @@ const ANSI_REGEX = * Cursor CLI provider implementation. */ export class CursorProvider extends BaseCliProvider { + readonly mcp: IProviderMcpRuntime = new CursorMcpRuntime(); + readonly skills: IProviderSkillsRuntime = new CursorSkillsRuntime(); + constructor() { super('cursor', { supportsRuntimePermissionRequests: false, diff --git a/server/src/modules/llm/providers/gemini.provider.ts b/server/src/modules/llm/providers/gemini.provider.ts index 20d30a61..b3ac21ae 100644 --- a/server/src/modules/llm/providers/gemini.provider.ts +++ b/server/src/modules/llm/providers/gemini.provider.ts @@ -1,5 +1,12 @@ import { BaseCliProvider } from '@/modules/llm/providers/base-cli.provider.js'; -import type { ProviderModel, StartSessionInput } from '@/modules/llm/providers/provider.interface.js'; +import type { + IProviderMcpRuntime, + IProviderSkillsRuntime, + ProviderModel, + StartSessionInput, +} from '@/modules/llm/providers/provider.interface.js'; +import { GeminiMcpRuntime } from '@/modules/llm/providers/runtimes/gemini-mcp.runtime.js'; +import { GeminiSkillsRuntime } from '@/modules/llm/providers/runtimes/gemini-skills.runtime.js'; type GeminiExecutionInput = StartSessionInput & { sessionId: string; @@ -22,6 +29,9 @@ const GEMINI_MODELS: ProviderModel[] = [ * Gemini CLI provider implementation. */ export class GeminiProvider extends BaseCliProvider { + readonly mcp: IProviderMcpRuntime = new GeminiMcpRuntime(); + readonly skills: IProviderSkillsRuntime = new GeminiSkillsRuntime(); + constructor() { super('gemini', { supportsRuntimePermissionRequests: false, diff --git a/server/src/modules/llm/providers/provider.interface.ts b/server/src/modules/llm/providers/provider.interface.ts index 5c7e39dd..4554a75e 100644 --- a/server/src/modules/llm/providers/provider.interface.ts +++ b/server/src/modules/llm/providers/provider.interface.ts @@ -6,6 +6,12 @@ export type ProviderSessionStatus = 'running' | 'completed' | 'failed' | 'stoppe export type RuntimePermissionMode = 'ask' | 'allow' | 'deny'; +export type McpScope = 'user' | 'local' | 'project'; + +export type McpTransport = 'stdio' | 'http' | 'sse'; + +export type ProviderSkillScope = 'user' | 'project' | 'plugin' | 'repo' | 'admin' | 'system'; + /** * Advertises optional provider behaviors so route/service code can gate features. */ @@ -17,6 +23,57 @@ export type ProviderCapabilities = { supportsSessionStop: boolean; }; +/** + * 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; +}; + +/** + * Unified skill descriptor returned by provider skill runtimes. + */ +export type ProviderSkill = { + provider: LLMProvider; + scope: ProviderSkillScope; + name: string; + description?: string; + invocation: string; + filePath: string; + pluginName?: string; +}; + /** * Provider model descriptor normalized for frontend consumption. */ @@ -77,6 +134,8 @@ export interface IProvider { readonly id: LLMProvider; readonly family: ProviderExecutionFamily; readonly capabilities: ProviderCapabilities; + readonly mcp: IProviderMcpRuntime; + readonly skills: IProviderSkillsRuntime; listModels(): Promise; @@ -89,6 +148,36 @@ export interface IProvider { listSessions(): ProviderSessionSnapshot[]; } +/** + * 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; + }>; +} + +/** + * Skills runtime contract for one provider. + */ +export interface IProviderSkillsRuntime { + listSkills(options?: { workspacePath?: string }): Promise; +} + /** * Internal mutable session state used by provider base classes. */ diff --git a/server/src/modules/llm/providers/runtimes/base-provider-mcp.runtime.ts b/server/src/modules/llm/providers/runtimes/base-provider-mcp.runtime.ts new file mode 100644 index 00000000..6fddb770 --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/base-provider-mcp.runtime.ts @@ -0,0 +1,236 @@ +import type { LLMProvider } from '@/shared/types/app.js'; +import { AppError } from '@/shared/utils/app-error.js'; +import type { + IProviderMcpRuntime, + McpScope, + McpTransport, + ProviderMcpServer, + UpsertProviderMcpServerInput, +} from '@/modules/llm/providers/provider.interface.js'; +import { + normalizeServerName, + resolveWorkspacePath, + runHttpServerProbe, + runStdioServerProbe, +} from '@/modules/llm/providers/runtimes/mcp-runtime.utils.js'; + +/** + * Shared MCP runtime for provider-specific config readers/writers. + */ +export abstract class BaseProviderMcpRuntime 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; + } + + /** + * Lists MCP servers grouped by user/local/project scopes. + */ + 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; + } + + /** + * Lists MCP servers for one scope. + */ + 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); + } + + /** + * Adds or updates one MCP server. + */ + 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, + }; + } + + /** + * Removes one MCP server for the selected scope. + */ + 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 }; + } + + /** + * Executes a lightweight startup/connectivity probe for one configured MCP server. + */ + 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, + }; + } + + /** + * Reads one scope's raw server map from provider-native files. + */ + protected abstract readScopedServers( + scope: McpScope, + workspacePath: string, + ): Promise>; + + /** + * Persists one scope's raw server map back to provider-native files. + */ + protected abstract writeScopedServers( + scope: McpScope, + workspacePath: string, + servers: Record, + ): Promise; + + /** + * Creates one provider-native config object from a unified input payload. + */ + protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record; + + /** + * Maps one provider-native server object into the unified response shape. + */ + protected abstract normalizeServerConfig( + scope: McpScope, + name: string, + rawConfig: unknown, + ): ProviderMcpServer | null; + + /** + * Ensures one scope is supported for the current provider. + */ + 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, + }); + } + } + + /** + * Ensures one scope + transport pair is supported for the current provider. + */ + 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/src/modules/llm/providers/runtimes/claude-mcp.runtime.ts b/server/src/modules/llm/providers/runtimes/claude-mcp.runtime.ts new file mode 100644 index 00000000..0e29ddf5 --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/claude-mcp.runtime.ts @@ -0,0 +1,154 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { AppError } from '@/shared/utils/app-error.js'; +import type { + McpScope, + ProviderMcpServer, + UpsertProviderMcpServerInput, +} from '@/modules/llm/providers/provider.interface.js'; +import { BaseProviderMcpRuntime } from '@/modules/llm/providers/runtimes/base-provider-mcp.runtime.js'; +import { + readJsonConfig, + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, + writeJsonConfig, +} from '@/modules/llm/providers/runtimes/mcp-runtime.utils.js'; + +/** + * Claude MCP runtime backed by `~/.claude.json` and project `.mcp.json`. + */ +export class ClaudeMcpRuntime extends BaseProviderMcpRuntime { + constructor() { + super('claude', ['user', 'local', 'project'], ['stdio', 'http', 'sse']); + } + + /** + * Reads Claude MCP servers from user/local/project config locations. + */ + 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) ?? {}; + } + + /** + * Writes Claude MCP servers to user/local/project config locations. + */ + 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); + } + + /** + * Builds one Claude-native server object from the unified input payload. + */ + 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 ?? {}, + }; + } + + /** + * Normalizes one Claude server object. + */ + 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/src/modules/llm/providers/runtimes/claude-skills.runtime.ts b/server/src/modules/llm/providers/runtimes/claude-skills.runtime.ts new file mode 100644 index 00000000..f63be028 --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/claude-skills.runtime.ts @@ -0,0 +1,133 @@ +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import type { IProviderSkillsRuntime, ProviderSkill } from '@/modules/llm/providers/provider.interface.js'; +import { deduplicateSkills, listSkillsFromDirectory } from '@/modules/llm/providers/runtimes/skills-runtime.utils.js'; + +/** + * Claude skills runtime backed by user/project/plugin skill directories. + */ +export class ClaudeSkillsRuntime implements IProviderSkillsRuntime { + /** + * Lists all available Claude skills from user/project/plugin locations. + */ + async listSkills(options?: { workspacePath?: string }): Promise { + const workspacePath = path.resolve(options?.workspacePath ?? process.cwd()); + const home = os.homedir(); + const skills: ProviderSkill[] = []; + + skills.push( + ...(await listSkillsFromDirectory({ + provider: 'claude', + scope: 'user', + skillsDirectory: path.join(home, '.claude', 'skills'), + invocationPrefix: '/', + })), + ); + + skills.push( + ...(await listSkillsFromDirectory({ + provider: 'claude', + scope: 'project', + skillsDirectory: path.join(workspacePath, '.claude', 'skills'), + invocationPrefix: '/', + })), + ); + + const enabledPlugins = await this.readClaudeEnabledPlugins(); + if (!enabledPlugins.length) { + return skills; + } + + const installedPluginIndex = await this.readClaudeInstalledPluginIndex(); + for (const pluginId of enabledPlugins) { + const pluginInstalls = installedPluginIndex[pluginId]; + if (!Array.isArray(pluginInstalls)) { + continue; + } + + const pluginNamespace = pluginId.split('@')[0] ?? pluginId; + for (const install of pluginInstalls) { + if (!install || typeof install !== 'object') { + continue; + } + const installPath = typeof (install as Record).installPath === 'string' + ? (install as Record).installPath as string + : ''; + if (!installPath) { + continue; + } + + const pluginSkills = await listSkillsFromDirectory({ + provider: 'claude', + scope: 'plugin', + skillsDirectory: path.join(installPath, 'skills'), + invocationPrefix: '/', + pluginName: pluginNamespace, + }); + + for (const skill of pluginSkills) { + skill.invocation = `/${pluginNamespace}:${skill.name}`; + skill.pluginName = pluginNamespace; + } + + skills.push(...pluginSkills); + } + } + + return deduplicateSkills(skills); + } + + /** + * Reads Claude enabled plugin map from `~/.claude/settings.json`. + */ + private async readClaudeEnabledPlugins(): Promise { + const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + try { + const settingsContent = await readFile(settingsPath, 'utf8'); + const settings = JSON.parse(settingsContent) as Record; + const enabledPlugins = settings.enabledPlugins; + if (!enabledPlugins || typeof enabledPlugins !== 'object' || Array.isArray(enabledPlugins)) { + return []; + } + + const enabledRecords = enabledPlugins as Record; + return Object.entries(enabledRecords) + .filter(([, enabled]) => enabled === true) + .map(([pluginId]) => pluginId); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + } + + /** + * Reads Claude installed plugin index from `~/.claude/plugins/installed_plugins.json`. + */ + private async readClaudeInstalledPluginIndex(): Promise> { + const pluginIndexPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'); + try { + const indexContent = await readFile(pluginIndexPath, 'utf8'); + const index = JSON.parse(indexContent) as Record; + const plugins = index.plugins; + if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) { + return {}; + } + + const normalized: Record = {}; + for (const [pluginId, entries] of Object.entries(plugins as Record)) { + normalized[pluginId] = Array.isArray(entries) ? entries : []; + } + + return normalized; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw error; + } + } +} diff --git a/server/src/modules/llm/providers/runtimes/codex-mcp.runtime.ts b/server/src/modules/llm/providers/runtimes/codex-mcp.runtime.ts new file mode 100644 index 00000000..f732e7db --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/codex-mcp.runtime.ts @@ -0,0 +1,133 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { AppError } from '@/shared/utils/app-error.js'; +import type { + McpScope, + ProviderMcpServer, + UpsertProviderMcpServerInput, +} from '@/modules/llm/providers/provider.interface.js'; +import { BaseProviderMcpRuntime } from '@/modules/llm/providers/runtimes/base-provider-mcp.runtime.js'; +import { + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, + readTomlConfig, + writeTomlConfig, +} from '@/modules/llm/providers/runtimes/mcp-runtime.utils.js'; + +/** + * Codex MCP runtime backed by user/project `.codex/config.toml`. + */ +export class CodexMcpRuntime extends BaseProviderMcpRuntime { + constructor() { + super('codex', ['user', 'project'], ['stdio', 'http']); + } + + /** + * Reads Codex MCP servers from user/project config.toml scopes. + */ + 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) ?? {}; + } + + /** + * Writes Codex MCP servers to user/project config.toml scopes. + */ + 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); + } + + /** + * Builds one Codex-native server object from the unified input payload. + */ + 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 ?? {}, + }; + } + + /** + * Normalizes one Codex server object. + */ + 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/src/modules/llm/providers/runtimes/codex-skills.runtime.ts b/server/src/modules/llm/providers/runtimes/codex-skills.runtime.ts new file mode 100644 index 00000000..b2a6dbec --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/codex-skills.runtime.ts @@ -0,0 +1,47 @@ +import os from 'node:os'; +import path from 'node:path'; + +import type { IProviderSkillsRuntime, ProviderSkill, ProviderSkillScope } from '@/modules/llm/providers/provider.interface.js'; +import { + deduplicateDirectories, + deduplicateSkills, + findGitRepoRoot, + listSkillsFromDirectory, +} from '@/modules/llm/providers/runtimes/skills-runtime.utils.js'; + +/** + * Codex skills runtime backed by repo/user/admin/system skill directories. + */ +export class CodexSkillsRuntime implements IProviderSkillsRuntime { + /** + * Lists all available Codex skills from documented directories. + */ + async listSkills(options?: { workspacePath?: string }): Promise { + const workspacePath = path.resolve(options?.workspacePath ?? process.cwd()); + const home = os.homedir(); + const repoRoot = await findGitRepoRoot(workspacePath); + const candidateDirectories: Array<{ scope: ProviderSkillScope; directory: string }> = [ + { scope: 'repo', directory: path.join(workspacePath, '.agents', 'skills') }, + { scope: 'repo', directory: path.join(workspacePath, '..', '.agents', 'skills') }, + { scope: 'user', directory: path.join(home, '.agents', 'skills') }, + { scope: 'admin', directory: path.join(path.sep, 'etc', 'codex', 'skills') }, + { scope: 'system', directory: path.join(home, '.codex', 'skills', '.system') }, + ]; + if (repoRoot) { + candidateDirectories.push({ scope: 'repo', directory: path.join(repoRoot, '.agents', 'skills') }); + } + + const skills: ProviderSkill[] = []; + for (const candidate of deduplicateDirectories(candidateDirectories)) { + const loadedSkills = await listSkillsFromDirectory({ + provider: 'codex', + scope: candidate.scope, + skillsDirectory: candidate.directory, + invocationPrefix: '$', + }); + skills.push(...loadedSkills); + } + + return deduplicateSkills(skills); + } +} diff --git a/server/src/modules/llm/providers/runtimes/cursor-mcp.runtime.ts b/server/src/modules/llm/providers/runtimes/cursor-mcp.runtime.ts new file mode 100644 index 00000000..77b05295 --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/cursor-mcp.runtime.ts @@ -0,0 +1,127 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { AppError } from '@/shared/utils/app-error.js'; +import type { + McpScope, + ProviderMcpServer, + UpsertProviderMcpServerInput, +} from '@/modules/llm/providers/provider.interface.js'; +import { BaseProviderMcpRuntime } from '@/modules/llm/providers/runtimes/base-provider-mcp.runtime.js'; +import { + readJsonConfig, + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, + writeJsonConfig, +} from '@/modules/llm/providers/runtimes/mcp-runtime.utils.js'; + +/** + * Cursor MCP runtime backed by user/project `.cursor/mcp.json`. + */ +export class CursorMcpRuntime extends BaseProviderMcpRuntime { + constructor() { + super('cursor', ['user', 'project'], ['stdio', 'http', 'sse']); + } + + /** + * Reads Cursor MCP servers from user/project config files. + */ + 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) ?? {}; + } + + /** + * Writes Cursor MCP servers to user/project config files. + */ + 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); + } + + /** + * Builds one Cursor-native server object from the unified input payload. + */ + 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 ?? {}, + }; + } + + /** + * Normalizes one Cursor server object. + */ + 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/src/modules/llm/providers/runtimes/cursor-skills.runtime.ts b/server/src/modules/llm/providers/runtimes/cursor-skills.runtime.ts new file mode 100644 index 00000000..e550f3ae --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/cursor-skills.runtime.ts @@ -0,0 +1,40 @@ +import os from 'node:os'; +import path from 'node:path'; + +import type { IProviderSkillsRuntime, ProviderSkill, ProviderSkillScope } from '@/modules/llm/providers/provider.interface.js'; +import { + deduplicateDirectories, + deduplicateSkills, + listSkillsFromDirectory, +} from '@/modules/llm/providers/runtimes/skills-runtime.utils.js'; + +/** + * Cursor skills runtime backed by user/project skill directories. + */ +export class CursorSkillsRuntime implements IProviderSkillsRuntime { + /** + * Lists all available Cursor skills from documented directories. + */ + async listSkills(options?: { workspacePath?: string }): Promise { + const workspacePath = path.resolve(options?.workspacePath ?? process.cwd()); + const home = os.homedir(); + const candidateDirectories: Array<{ scope: ProviderSkillScope; directory: string }> = [ + { scope: 'project', directory: path.join(workspacePath, '.agents', 'skills') }, + { scope: 'project', directory: path.join(workspacePath, '.cursor', 'skills') }, + { scope: 'user', directory: path.join(home, '.cursor', 'skills') }, + ]; + + const skills: ProviderSkill[] = []; + for (const candidate of deduplicateDirectories(candidateDirectories)) { + const loadedSkills = await listSkillsFromDirectory({ + provider: 'cursor', + scope: candidate.scope, + skillsDirectory: candidate.directory, + invocationPrefix: '/', + }); + skills.push(...loadedSkills); + } + + return deduplicateSkills(skills); + } +} diff --git a/server/src/modules/llm/providers/runtimes/gemini-mcp.runtime.ts b/server/src/modules/llm/providers/runtimes/gemini-mcp.runtime.ts new file mode 100644 index 00000000..48c46207 --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/gemini-mcp.runtime.ts @@ -0,0 +1,129 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { AppError } from '@/shared/utils/app-error.js'; +import type { + McpScope, + ProviderMcpServer, + UpsertProviderMcpServerInput, +} from '@/modules/llm/providers/provider.interface.js'; +import { BaseProviderMcpRuntime } from '@/modules/llm/providers/runtimes/base-provider-mcp.runtime.js'; +import { + readJsonConfig, + readObjectRecord, + readOptionalString, + readStringArray, + readStringRecord, + writeJsonConfig, +} from '@/modules/llm/providers/runtimes/mcp-runtime.utils.js'; + +/** + * Gemini MCP runtime backed by user/project `.gemini/settings.json`. + */ +export class GeminiMcpRuntime extends BaseProviderMcpRuntime { + constructor() { + super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']); + } + + /** + * Reads Gemini MCP servers from user/project config files. + */ + 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) ?? {}; + } + + /** + * Writes Gemini MCP servers to user/project config files. + */ + 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); + } + + /** + * Builds one Gemini-native server object from the unified input payload. + */ + 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 ?? {}, + }; + } + + /** + * Normalizes one Gemini server object. + */ + 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/src/modules/llm/providers/runtimes/gemini-skills.runtime.ts b/server/src/modules/llm/providers/runtimes/gemini-skills.runtime.ts new file mode 100644 index 00000000..c51b7e18 --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/gemini-skills.runtime.ts @@ -0,0 +1,41 @@ +import os from 'node:os'; +import path from 'node:path'; + +import type { IProviderSkillsRuntime, ProviderSkill, ProviderSkillScope } from '@/modules/llm/providers/provider.interface.js'; +import { + deduplicateDirectories, + deduplicateSkills, + listSkillsFromDirectory, +} from '@/modules/llm/providers/runtimes/skills-runtime.utils.js'; + +/** + * Gemini skills runtime backed by user/project skill directories. + */ +export class GeminiSkillsRuntime implements IProviderSkillsRuntime { + /** + * Lists all available Gemini skills from documented directories. + */ + async listSkills(options?: { workspacePath?: string }): Promise { + const workspacePath = path.resolve(options?.workspacePath ?? process.cwd()); + const home = os.homedir(); + const candidateDirectories: Array<{ scope: ProviderSkillScope; directory: string }> = [ + { scope: 'user', directory: path.join(home, '.gemini', 'skills') }, + { scope: 'user', directory: path.join(home, '.agents', 'skills') }, + { scope: 'project', directory: path.join(workspacePath, '.gemini', 'skills') }, + { scope: 'project', directory: path.join(workspacePath, '.agents', 'skills') }, + ]; + + const skills: ProviderSkill[] = []; + for (const candidate of deduplicateDirectories(candidateDirectories)) { + const loadedSkills = await listSkillsFromDirectory({ + provider: 'gemini', + scope: candidate.scope, + skillsDirectory: candidate.directory, + invocationPrefix: '/', + }); + skills.push(...loadedSkills); + } + + return deduplicateSkills(skills); + } +} diff --git a/server/src/modules/llm/providers/runtimes/mcp-runtime.utils.ts b/server/src/modules/llm/providers/runtimes/mcp-runtime.utils.ts new file mode 100644 index 00000000..87305e14 --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/mcp-runtime.utils.ts @@ -0,0 +1,207 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { once } from 'node:events'; + +import spawn from 'cross-spawn'; +import TOML from '@iarna/toml'; + +import type { ProviderMcpServer } from '@/modules/llm/providers/provider.interface.js'; +import { AppError } from '@/shared/utils/app-error.js'; + +/** + * Resolves workspace paths once so all scope loaders read from a consistent absolute root. + */ +export const resolveWorkspacePath = (workspacePath?: string): string => + path.resolve(workspacePath ?? process.cwd()); + +/** + * Restricts MCP server names to non-empty trimmed strings. + */ +export 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; +}; + +/** + * Reads plain object records. + */ +export const readObjectRecord = (value: unknown): Record | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as Record; +}; + +/** + * Reads optional strings. + */ +export const readOptionalString = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length ? normalized : undefined; +}; + +/** + * Reads optional string arrays. + */ +export const readStringArray = (value: unknown): string[] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + return value.filter((entry): entry is string => typeof entry === 'string'); +}; + +/** + * Reads optional string maps. + */ +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; +}; + +/** + * Safely reads a JSON config file and returns an empty object when missing. + */ +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 one JSON config with stable formatting. + */ +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'); +}; + +/** + * Safely reads a TOML config and returns an empty object when missing. + */ +export 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; + } +}; + +/** + * Writes one TOML config file. + */ +export const writeTomlConfig = async (filePath: string, data: Record): Promise => { + await mkdir(path.dirname(filePath), { recursive: true }); + const toml = TOML.stringify(data as any); + await writeFile(filePath, toml, 'utf8'); +}; + +/** + * Runs a short stdio process startup probe. + */ +export 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', + }; + } +}; + +/** + * Runs a lightweight HTTP/SSE reachability probe. + */ +export 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', + }; + } +}; diff --git a/server/src/modules/llm/providers/runtimes/skills-runtime.utils.ts b/server/src/modules/llm/providers/runtimes/skills-runtime.utils.ts new file mode 100644 index 00000000..f82133b3 --- /dev/null +++ b/server/src/modules/llm/providers/runtimes/skills-runtime.utils.ts @@ -0,0 +1,154 @@ +import { access, readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +import type { LLMProvider } from '@/shared/types/app.js'; +import type { ProviderSkill, ProviderSkillScope } from '@/modules/llm/providers/provider.interface.js'; + +/** + * Tests whether a path exists. + */ +export const pathExists = async (targetPath: string): Promise => { + try { + await access(targetPath); + return true; + } catch { + return false; + } +}; + +/** + * Parses frontmatter metadata from SKILL.md files. + */ +export const parseSkillFrontmatter = (content: string): { name?: string; description?: string } => { + if (!content.startsWith('---')) { + return {}; + } + + const closingDelimiterIndex = content.indexOf('\n---', 3); + if (closingDelimiterIndex < 0) { + return {}; + } + + const frontmatter = content.slice(3, closingDelimiterIndex).trim(); + const metadata: { name?: string; description?: string } = {}; + for (const line of frontmatter.split(/\r?\n/)) { + const separatorIndex = line.indexOf(':'); + if (separatorIndex <= 0) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + const rawValue = line.slice(separatorIndex + 1).trim(); + const value = rawValue.replace(/^["']|["']$/g, ''); + if (key === 'name') { + metadata.name = value; + } else if (key === 'description') { + metadata.description = value; + } + } + + return metadata; +}; + +/** + * Reads SKILL.md files from a `//SKILL.md` directory layout. + */ +export const listSkillsFromDirectory = async (input: { + provider: LLMProvider; + scope: ProviderSkillScope; + skillsDirectory: string; + invocationPrefix: '/' | '$'; + pluginName?: string; +}): Promise => { + if (!(await pathExists(input.skillsDirectory))) { + return []; + } + + const entries = await readdir(input.skillsDirectory, { withFileTypes: true }); + const skills: ProviderSkill[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const skillDirectory = path.join(input.skillsDirectory, entry.name); + const skillFilePath = path.join(skillDirectory, 'SKILL.md'); + if (!(await pathExists(skillFilePath))) { + continue; + } + + const skillMarkdown = await readFile(skillFilePath, 'utf8'); + const metadata = parseSkillFrontmatter(skillMarkdown); + const skillName = metadata.name ?? entry.name; + const invocation = `${input.invocationPrefix}${skillName}`; + skills.push({ + provider: input.provider, + scope: input.scope, + name: skillName, + description: metadata.description, + invocation, + filePath: skillFilePath, + pluginName: input.pluginName, + }); + } + + return skills; +}; + +/** + * Finds the closest git root by walking up from the current workspace path. + */ +export const findGitRepoRoot = async (startPath: string): Promise => { + let currentPath = path.resolve(startPath); + while (true) { + const gitPath = path.join(currentPath, '.git'); + if (await pathExists(gitPath)) { + return currentPath; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return null; + } + + currentPath = parentPath; + } +}; + +/** + * Deduplicates directory candidates by absolute path. + */ +export const deduplicateDirectories = ( + entries: Array<{ scope: ProviderSkillScope; directory: string }>, +): Array<{ scope: ProviderSkillScope; directory: string }> => { + const seen = new Set(); + const deduplicated: Array<{ scope: ProviderSkillScope; directory: string }> = []; + for (const entry of entries) { + const normalizedDirectory = path.resolve(entry.directory); + if (seen.has(normalizedDirectory)) { + continue; + } + seen.add(normalizedDirectory); + deduplicated.push({ scope: entry.scope, directory: normalizedDirectory }); + } + + return deduplicated; +}; + +/** + * Deduplicates skills by provider + invocation command. + */ +export const deduplicateSkills = (skills: ProviderSkill[]): ProviderSkill[] => { + const seen = new Set(); + const deduplicated: ProviderSkill[] = []; + for (const skill of skills) { + const key = `${skill.provider}:${skill.invocation}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduplicated.push(skill); + } + + return deduplicated; +}; diff --git a/server/src/modules/llm/services/llm.service.ts b/server/src/modules/llm/services/llm.service.ts index 02470e0b..d28855da 100644 --- a/server/src/modules/llm/services/llm.service.ts +++ b/server/src/modules/llm/services/llm.service.ts @@ -2,10 +2,14 @@ import type { LLMProvider } from '@/shared/types/app.js'; import { AppError } from '@/shared/utils/app-error.js'; import { llmProviderRegistry } from '@/modules/llm/llm.registry.js'; import type { + McpScope, + ProviderMcpServer, ProviderModel, + ProviderSkill, ProviderSessionSnapshot, RuntimePermissionMode, StartSessionInput, + UpsertProviderMcpServerInput, } from '@/modules/llm/providers/provider.interface.js'; /** @@ -127,6 +131,113 @@ export const llmService = { const provider = llmProviderRegistry.resolveProvider(providerName); return provider.stopSession(sessionId); }, + + /** + * Lists MCP servers for one provider grouped by supported scopes. + */ + async listProviderMcpServers( + providerName: string, + options?: { workspacePath?: string }, + ): Promise> { + const provider = llmProviderRegistry.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 = llmProviderRegistry.resolveProvider(providerName); + return provider.mcp.listServersForScope(scope, options); + }, + + /** + * Adds or updates one provider MCP server. + */ + async upsertProviderMcpServer( + providerName: string, + input: UpsertProviderMcpServerInput, + ): Promise { + const provider = llmProviderRegistry.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 = llmProviderRegistry.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 = llmProviderRegistry.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 = llmProviderRegistry.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; + }, + + /** + * Lists skills for one provider. + */ + async listProviderSkills( + providerName: string, + options?: { workspacePath?: string }, + ): Promise { + const provider = llmProviderRegistry.resolveProvider(providerName); + return provider.skills.listSkills(options); + }, }; /** diff --git a/server/src/modules/llm/services/mcp.service.ts b/server/src/modules/llm/services/mcp.service.ts deleted file mode 100644 index e546e55c..00000000 --- a/server/src/modules/llm/services/mcp.service.ts +++ /dev/null @@ -1,817 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { once } from 'node:events'; - -import spawn from 'cross-spawn'; -import TOML from '@iarna/toml'; - -import type { LLMProvider } from '@/shared/types/app.js'; -import { AppError } from '@/shared/utils/app-error.js'; - -export type McpScope = 'user' | 'local' | 'project'; -export type McpTransport = 'stdio' | 'http' | 'sse'; - -export type UnifiedMcpServer = { - 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; -}; - -export type UpsertMcpServerInput = { - 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; -}; - -const PROVIDER_CAPABILITIES: Record = { - claude: { scopes: ['user', 'local', 'project'], transports: ['stdio', 'http', 'sse'] }, - codex: { scopes: ['user', 'project'], transports: ['stdio', 'http'] }, - cursor: { scopes: ['user', 'project'], transports: ['stdio', 'http', 'sse'] }, - gemini: { scopes: ['user', 'project'], transports: ['stdio', 'http', 'sse'] }, -}; - -const PROVIDERS: LLMProvider[] = ['claude', 'codex', 'cursor', 'gemini']; - -/** - * Unified MCP configuration service backed by provider-native config files. - */ -export const llmMcpService = { - /** - * Lists MCP servers for one provider grouped by user/local/project scopes. - */ - async listProviderServers( - provider: LLMProvider, - options?: { workspacePath?: string }, - ): Promise> { - const workspacePath = resolveWorkspacePath(options?.workspacePath); - const grouped: Record = { - user: [], - local: [], - project: [], - }; - - const capability = PROVIDER_CAPABILITIES[provider]; - for (const scope of capability.scopes) { - const servers = await this.listProviderServersForScope(provider, scope, workspacePath); - grouped[scope] = servers; - } - - return grouped; - }, - - /** - * Writes one MCP server definition into the provider's config file for the selected scope. - */ - async upsertProviderServer(provider: LLMProvider, input: UpsertMcpServerInput): Promise { - validateProviderScopeAndTransport(provider, input.scope ?? 'project', input.transport); - const scope = input.scope ?? 'project'; - const workspacePath = resolveWorkspacePath(input.workspacePath); - const normalizedName = normalizeServerName(input.name); - const scopedServers = await readScopedProviderServers(provider, scope, workspacePath); - scopedServers[normalizedName] = buildProviderServerConfig(provider, input); - await writeScopedProviderServers(provider, scope, workspacePath, scopedServers); - - return { - 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, - }; - }, - - /** - * Removes one MCP server definition from the provider's config file. - */ - async removeProviderServer( - provider: LLMProvider, - input: { name: string; scope?: McpScope; workspacePath?: string }, - ): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> { - const scope = input.scope ?? 'project'; - validateProviderScopeAndTransport(provider, scope, 'stdio'); - const workspacePath = resolveWorkspacePath(input.workspacePath); - const normalizedName = normalizeServerName(input.name); - const scopedServers = await readScopedProviderServers(provider, scope, workspacePath); - const removed = Object.prototype.hasOwnProperty.call(scopedServers, normalizedName); - if (removed) { - delete scopedServers[normalizedName]; - await writeScopedProviderServers(provider, scope, workspacePath, scopedServers); - } - - return { removed, provider, name: normalizedName, scope }; - }, - - /** - * Adds one MCP server to all providers using the same input shape. - */ - async addServerToAllProviders( - 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 }> = []; - for (const provider of PROVIDERS) { - try { - await this.upsertProviderServer(provider, { ...input, scope }); - results.push({ provider, created: true }); - } catch (error) { - results.push({ - provider, - created: false, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - - return results; - }, - - /** - * Performs a lightweight startup/connectivity check for one configured MCP server. - */ - async runProviderServer(input: { - provider: LLMProvider; - 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'; - const workspacePath = resolveWorkspacePath(input.workspacePath); - const normalizedName = normalizeServerName(input.name); - const scopedServers = await readScopedProviderServers(input.provider, 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 = normalizeServerConfig(input.provider, 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: input.provider, - name: normalizedName, - scope, - transport: normalized.transport, - reachable: result.reachable, - error: result.error, - }; - } - - const result = await runHttpServerProbe(normalized.url ?? ''); - return { - provider: input.provider, - name: normalizedName, - scope, - transport: normalized.transport, - reachable: result.reachable, - statusCode: result.statusCode, - error: result.error, - }; - }, - - /** - * Reads and normalizes one provider scope into unified MCP server records. - */ - async listProviderServersForScope( - provider: LLMProvider, - scope: McpScope, - workspacePath: string, - ): Promise { - if (!PROVIDER_CAPABILITIES[provider].scopes.includes(scope)) { - return []; - } - - const scopedServers = await readScopedProviderServers(provider, scope, workspacePath); - return Object.entries(scopedServers) - .map(([name, rawConfig]) => normalizeServerConfig(provider, scope, name, rawConfig)) - .filter((entry): entry is UnifiedMcpServer => entry !== null); - }, -}; - -/** - * Resolves workspace paths once so all scope loaders read from a consistent absolute root. - */ -function resolveWorkspacePath(workspacePath?: string): string { - return path.resolve(workspacePath ?? process.cwd()); -} - -/** - * Restricts MCP server names to non-empty trimmed strings. - */ -function 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; -} - -/** - * Applies provider capability checks before read/write operations. - */ -function validateProviderScopeAndTransport( - provider: LLMProvider, - scope: McpScope, - transport: McpTransport, -): void { - const capability = PROVIDER_CAPABILITIES[provider]; - if (!capability.scopes.includes(scope)) { - throw new AppError(`Provider "${provider}" does not support "${scope}" MCP scope.`, { - code: 'MCP_SCOPE_NOT_SUPPORTED', - statusCode: 400, - }); - } - - if (!capability.transports.includes(transport)) { - throw new AppError(`Provider "${provider}" does not support "${transport}" MCP transport.`, { - code: 'MCP_TRANSPORT_NOT_SUPPORTED', - statusCode: 400, - }); - } -} - -/** - * Loads one scope's raw server map from a provider-native config file. - */ -async function readScopedProviderServers( - provider: LLMProvider, - scope: McpScope, - workspacePath: string, -): Promise> { - switch (provider) { - case 'claude': - return readClaudeScopedServers(scope, workspacePath); - case 'codex': - return readCodexScopedServers(scope, workspacePath); - case 'cursor': - return readCursorScopedServers(scope, workspacePath); - case 'gemini': - return readGeminiScopedServers(scope, workspacePath); - default: - return {}; - } -} - -/** - * Persists one scope's raw server map back to provider-native config files. - */ -async function writeScopedProviderServers( - provider: LLMProvider, - scope: McpScope, - workspacePath: string, - servers: Record, -): Promise { - switch (provider) { - case 'claude': - await writeClaudeScopedServers(scope, workspacePath, servers); - return; - case 'codex': - await writeCodexScopedServers(scope, workspacePath, servers); - return; - case 'cursor': - await writeCursorScopedServers(scope, workspacePath, servers); - return; - case 'gemini': - await writeGeminiScopedServers(scope, workspacePath, servers); - return; - default: - return; - } -} - -/** - * Creates one provider-native server config object from unified input payload. - */ -function buildProviderServerConfig(provider: LLMProvider, input: UpsertMcpServerInput): Record { - const scope = input.scope ?? 'project'; - validateProviderScopeAndTransport(provider, scope, input.transport); - - if (input.transport === 'stdio') { - if (!input.command?.trim()) { - throw new AppError('command is required for stdio MCP servers.', { - code: 'MCP_COMMAND_REQUIRED', - statusCode: 400, - }); - } - - if (provider === 'claude') { - return { - type: 'stdio', - command: input.command, - args: input.args ?? [], - env: input.env ?? {}, - }; - } - - if (provider === 'codex') { - return { - command: input.command, - args: input.args ?? [], - env: input.env ?? {}, - env_vars: input.envVars ?? [], - cwd: input.cwd, - }; - } - - 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, - }); - } - - if (provider === 'codex') { - return { - url: input.url, - bearer_token_env_var: input.bearerTokenEnvVar, - http_headers: input.headers ?? {}, - env_http_headers: input.envHttpHeaders ?? {}, - }; - } - - if (provider === 'cursor') { - return { - url: input.url, - headers: input.headers ?? {}, - }; - } - - return { - type: input.transport, - url: input.url, - headers: input.headers ?? {}, - }; -} - -/** - * Maps one provider-native server object into the unified response shape. - */ -function normalizeServerConfig( - provider: LLMProvider, - scope: McpScope, - name: string, - rawConfig: unknown, -): UnifiedMcpServer | null { - if (!rawConfig || typeof rawConfig !== 'object') { - return null; - } - - const config = rawConfig as Record; - if (typeof config.command === 'string') { - const transport: McpTransport = 'stdio'; - return { - provider, - name, - scope, - transport, - 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') { - let transport: McpTransport = 'http'; - if (provider === 'claude' || provider === 'gemini') { - const typeValue = readOptionalString(config.type); - if (typeValue === 'sse') { - transport = 'sse'; - } - } - - return { - provider, - name, - scope, - transport, - url: config.url, - headers: readStringRecord(config.headers) ?? readStringRecord(config.http_headers), - bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var), - envHttpHeaders: readStringRecord(config.env_http_headers), - }; - } - - return null; -} - -/** - * Reads Claude MCP servers from ~/.claude.json and project .mcp.json files. - */ -async function readClaudeScopedServers(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) ?? {}; - } - - if (scope === 'local') { - const projects = readObjectRecord(config.projects) ?? {}; - const projectConfig = readObjectRecord(projects[workspacePath]) ?? {}; - return readObjectRecord(projectConfig.mcpServers) ?? {}; - } - - return {}; -} - -/** - * Persists Claude MCP servers back to ~/.claude.json or .mcp.json depending on scope. - */ -async function writeClaudeScopedServers( - 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); -} - -/** - * Reads Codex MCP servers from config.toml user or project scopes. - */ -async function readCodexScopedServers(scope: McpScope, workspacePath: string): Promise> { - if (scope === 'local') { - throw new AppError('Codex does not support local MCP scope.', { - code: 'MCP_SCOPE_NOT_SUPPORTED', - statusCode: 400, - }); - } - - 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) ?? {}; -} - -/** - * Persists Codex MCP servers to config.toml user/project scopes. - */ -async function writeCodexScopedServers( - scope: McpScope, - workspacePath: string, - servers: Record, -): Promise { - if (scope === 'local') { - throw new AppError('Codex does not support local MCP scope.', { - code: 'MCP_SCOPE_NOT_SUPPORTED', - statusCode: 400, - }); - } - - 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); -} - -/** - * Reads Gemini MCP servers from settings.json user/project scopes. - */ -async function readGeminiScopedServers(scope: McpScope, workspacePath: string): Promise> { - if (scope === 'local') { - throw new AppError('Gemini does not support local MCP scope.', { - code: 'MCP_SCOPE_NOT_SUPPORTED', - statusCode: 400, - }); - } - - 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) ?? {}; -} - -/** - * Persists Gemini MCP servers to settings.json user/project scopes. - */ -async function writeGeminiScopedServers( - scope: McpScope, - workspacePath: string, - servers: Record, -): Promise { - if (scope === 'local') { - throw new AppError('Gemini does not support local MCP scope.', { - code: 'MCP_SCOPE_NOT_SUPPORTED', - statusCode: 400, - }); - } - - 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); -} - -/** - * Reads Cursor MCP servers from mcp.json user/project scopes. - */ -async function readCursorScopedServers(scope: McpScope, workspacePath: string): Promise> { - if (scope === 'local') { - throw new AppError('Cursor does not support local MCP scope.', { - code: 'MCP_SCOPE_NOT_SUPPORTED', - statusCode: 400, - }); - } - - 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) ?? {}; -} - -/** - * Persists Cursor MCP servers to mcp.json user/project scopes. - */ -async function writeCursorScopedServers( - scope: McpScope, - workspacePath: string, - servers: Record, -): Promise { - if (scope === 'local') { - throw new AppError('Cursor does not support local MCP scope.', { - code: 'MCP_SCOPE_NOT_SUPPORTED', - statusCode: 400, - }); - } - - 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); -} - -/** - * Runs a short stdio process startup probe. - */ -async function runStdioServerProbe( - server: UnifiedMcpServer, - 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', - }; - } -} - -/** - * Runs a lightweight HTTP/SSE reachability probe. - */ -async function runHttpServerProbe(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', - }; - } -} - -/** - * Safely reads a JSON config file and returns an empty object when missing. - */ -async function readJsonConfig(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 one JSON config with stable formatting. - */ -async function writeJsonConfig(filePath: string, data: Record): Promise { - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); -} - -/** - * Safely reads a TOML config and returns an empty object when missing. - */ -async function readTomlConfig(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; - } -} - -/** - * Writes one TOML config file. - */ -async function writeTomlConfig(filePath: string, data: Record): Promise { - await mkdir(path.dirname(filePath), { recursive: true }); - const toml = TOML.stringify(data as any); - await writeFile(filePath, toml, 'utf8'); -} - -/** - * Reads plain object records. - */ -function readObjectRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null; - } - return value as Record; -} - -/** - * Reads optional strings. - */ -function readOptionalString(value: unknown): string | undefined { - if (typeof value !== 'string') { - return undefined; - } - - const normalized = value.trim(); - return normalized.length ? normalized : undefined; -} - -/** - * Reads optional string arrays. - */ -function readStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - - return value.filter((entry): entry is string => typeof entry === 'string'); -} - -/** - * Reads optional string maps. - */ -function 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; -} diff --git a/server/src/modules/llm/services/skills.service.ts b/server/src/modules/llm/services/skills.service.ts deleted file mode 100644 index b53e4930..00000000 --- a/server/src/modules/llm/services/skills.service.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { access, readFile, readdir } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; - -import type { LLMProvider } from '@/shared/types/app.js'; - -export type SkillScope = 'user' | 'project' | 'plugin' | 'repo' | 'admin' | 'system'; - -export type UnifiedSkill = { - provider: LLMProvider; - scope: SkillScope; - name: string; - description?: string; - invocation: string; - filePath: string; - pluginName?: string; -}; - -/** - * Unified provider skills loader used by the refactor LLM module. - */ -export const llmSkillsService = { - /** - * Lists all available skills for one provider from provider-specific skill directories. - */ - async listProviderSkills( - provider: LLMProvider, - options?: { workspacePath?: string }, - ): Promise { - const workspacePath = path.resolve(options?.workspacePath ?? process.cwd()); - switch (provider) { - case 'claude': - return listClaudeSkills(workspacePath); - case 'codex': - return listCodexSkills(workspacePath); - case 'cursor': - return listCursorSkills(workspacePath); - case 'gemini': - return listGeminiSkills(workspacePath); - default: - return []; - } - }, -}; - -/** - * Reads Claude user/project skills and plugin skills with plugin namespace commands. - */ -async function listClaudeSkills(workspacePath: string): Promise { - const home = os.homedir(); - const skills: UnifiedSkill[] = []; - - skills.push( - ...(await listSkillsFromDirectory({ - provider: 'claude', - scope: 'user', - skillsDirectory: path.join(home, '.claude', 'skills'), - invocationPrefix: '/', - })), - ); - - skills.push( - ...(await listSkillsFromDirectory({ - provider: 'claude', - scope: 'project', - skillsDirectory: path.join(workspacePath, '.claude', 'skills'), - invocationPrefix: '/', - })), - ); - - const enabledPlugins = await readClaudeEnabledPlugins(); - if (!enabledPlugins.length) { - return skills; - } - - const installedPluginIndex = await readClaudeInstalledPluginIndex(); - for (const pluginId of enabledPlugins) { - const pluginInstalls = installedPluginIndex[pluginId]; - if (!Array.isArray(pluginInstalls)) { - continue; - } - - const pluginNamespace = pluginId.split('@')[0] ?? pluginId; - for (const install of pluginInstalls) { - if (!install || typeof install !== 'object') { - continue; - } - const installPath = typeof (install as Record).installPath === 'string' - ? (install as Record).installPath as string - : ''; - if (!installPath) { - continue; - } - - const pluginSkills = await listSkillsFromDirectory({ - provider: 'claude', - scope: 'plugin', - skillsDirectory: path.join(installPath, 'skills'), - invocationPrefix: '/', - pluginName: pluginNamespace, - }); - - for (const skill of pluginSkills) { - skill.invocation = `/${pluginNamespace}:${skill.name}`; - skill.pluginName = pluginNamespace; - } - - skills.push(...pluginSkills); - } - } - - return deduplicateSkills(skills); -} - -/** - * Reads Codex skills from repo/user/admin/system locations. - */ -async function listCodexSkills(workspacePath: string): Promise { - const home = os.homedir(); - const repoRoot = await findGitRepoRoot(workspacePath); - const candidateDirectories: Array<{ scope: SkillScope; directory: string }> = [ - { scope: 'repo', directory: path.join(workspacePath, '.agents', 'skills') }, - { scope: 'repo', directory: path.join(workspacePath, '..', '.agents', 'skills') }, - { scope: 'user', directory: path.join(home, '.agents', 'skills') }, - { scope: 'admin', directory: path.join(path.sep, 'etc', 'codex', 'skills') }, - { scope: 'system', directory: path.join(home, '.codex', 'skills', '.system') }, - ]; - if (repoRoot) { - candidateDirectories.push({ scope: 'repo', directory: path.join(repoRoot, '.agents', 'skills') }); - } - - const skills: UnifiedSkill[] = []; - for (const candidate of deduplicateDirectories(candidateDirectories)) { - const loadedSkills = await listSkillsFromDirectory({ - provider: 'codex', - scope: candidate.scope, - skillsDirectory: candidate.directory, - invocationPrefix: '$', - }); - skills.push(...loadedSkills); - } - - return deduplicateSkills(skills); -} - -/** - * Reads Gemini user/project skill directories. - */ -async function listGeminiSkills(workspacePath: string): Promise { - const home = os.homedir(); - const candidateDirectories: Array<{ scope: SkillScope; directory: string }> = [ - { scope: 'user', directory: path.join(home, '.gemini', 'skills') }, - { scope: 'user', directory: path.join(home, '.agents', 'skills') }, - { scope: 'project', directory: path.join(workspacePath, '.gemini', 'skills') }, - { scope: 'project', directory: path.join(workspacePath, '.agents', 'skills') }, - ]; - - const skills: UnifiedSkill[] = []; - for (const candidate of deduplicateDirectories(candidateDirectories)) { - const loadedSkills = await listSkillsFromDirectory({ - provider: 'gemini', - scope: candidate.scope, - skillsDirectory: candidate.directory, - invocationPrefix: '/', - }); - skills.push(...loadedSkills); - } - - return deduplicateSkills(skills); -} - -/** - * Reads Cursor user/project skill directories. - */ -async function listCursorSkills(workspacePath: string): Promise { - const home = os.homedir(); - const candidateDirectories: Array<{ scope: SkillScope; directory: string }> = [ - { scope: 'project', directory: path.join(workspacePath, '.agents', 'skills') }, - { scope: 'project', directory: path.join(workspacePath, '.cursor', 'skills') }, - { scope: 'user', directory: path.join(home, '.cursor', 'skills') }, - ]; - - const skills: UnifiedSkill[] = []; - for (const candidate of deduplicateDirectories(candidateDirectories)) { - const loadedSkills = await listSkillsFromDirectory({ - provider: 'cursor', - scope: candidate.scope, - skillsDirectory: candidate.directory, - invocationPrefix: '/', - }); - skills.push(...loadedSkills); - } - - return deduplicateSkills(skills); -} - -/** - * Reads SKILL.md files from a `//SKILL.md` directory layout. - */ -async function listSkillsFromDirectory(input: { - provider: LLMProvider; - scope: SkillScope; - skillsDirectory: string; - invocationPrefix: '/' | '$'; - pluginName?: string; -}): Promise { - if (!(await pathExists(input.skillsDirectory))) { - return []; - } - - const entries = await readdir(input.skillsDirectory, { withFileTypes: true }); - const skills: UnifiedSkill[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const skillDirectory = path.join(input.skillsDirectory, entry.name); - const skillFilePath = path.join(skillDirectory, 'SKILL.md'); - if (!(await pathExists(skillFilePath))) { - continue; - } - - const skillMarkdown = await readFile(skillFilePath, 'utf8'); - const metadata = parseSkillFrontmatter(skillMarkdown); - const skillName = metadata.name ?? entry.name; - const invocation = `${input.invocationPrefix}${skillName}`; - skills.push({ - provider: input.provider, - scope: input.scope, - name: skillName, - description: metadata.description, - invocation, - filePath: skillFilePath, - pluginName: input.pluginName, - }); - } - - return skills; -} - -/** - * Parses frontmatter metadata from SKILL.md files. - */ -function parseSkillFrontmatter(content: string): { name?: string; description?: string } { - if (!content.startsWith('---')) { - return {}; - } - - const closingDelimiterIndex = content.indexOf('\n---', 3); - if (closingDelimiterIndex < 0) { - return {}; - } - - const frontmatter = content.slice(3, closingDelimiterIndex).trim(); - const metadata: { name?: string; description?: string } = {}; - for (const line of frontmatter.split(/\r?\n/)) { - const separatorIndex = line.indexOf(':'); - if (separatorIndex <= 0) { - continue; - } - - const key = line.slice(0, separatorIndex).trim(); - const rawValue = line.slice(separatorIndex + 1).trim(); - const value = rawValue.replace(/^["']|["']$/g, ''); - if (key === 'name') { - metadata.name = value; - } else if (key === 'description') { - metadata.description = value; - } - } - - return metadata; -} - -/** - * Reads Claude enabled plugin map from ~/.claude/settings.json. - */ -async function readClaudeEnabledPlugins(): Promise { - const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); - try { - const settingsContent = await readFile(settingsPath, 'utf8'); - const settings = JSON.parse(settingsContent) as Record; - const enabledPlugins = settings.enabledPlugins; - if (!enabledPlugins || typeof enabledPlugins !== 'object' || Array.isArray(enabledPlugins)) { - return []; - } - - const enabledRecords = enabledPlugins as Record; - return Object.entries(enabledRecords) - .filter(([, enabled]) => enabled === true) - .map(([pluginId]) => pluginId); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return []; - } - throw error; - } -} - -/** - * Reads Claude installed plugin index from ~/.claude/plugins/installed_plugins.json. - */ -async function readClaudeInstalledPluginIndex(): Promise> { - const pluginIndexPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'); - try { - const indexContent = await readFile(pluginIndexPath, 'utf8'); - const index = JSON.parse(indexContent) as Record; - const plugins = index.plugins; - if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) { - return {}; - } - - const normalized: Record = {}; - for (const [pluginId, entries] of Object.entries(plugins as Record)) { - normalized[pluginId] = Array.isArray(entries) ? entries : []; - } - - return normalized; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return {}; - } - throw error; - } -} - -/** - * Finds the closest git root by walking up from the current workspace path. - */ -async function findGitRepoRoot(startPath: string): Promise { - let currentPath = path.resolve(startPath); - while (true) { - const gitPath = path.join(currentPath, '.git'); - if (await pathExists(gitPath)) { - return currentPath; - } - - const parentPath = path.dirname(currentPath); - if (parentPath === currentPath) { - return null; - } - - currentPath = parentPath; - } -} - -/** - * Deduplicates directory candidates by absolute path. - */ -function deduplicateDirectories( - entries: Array<{ scope: SkillScope; directory: string }>, -): Array<{ scope: SkillScope; directory: string }> { - const seen = new Set(); - const deduplicated: Array<{ scope: SkillScope; directory: string }> = []; - for (const entry of entries) { - const normalizedDirectory = path.resolve(entry.directory); - if (seen.has(normalizedDirectory)) { - continue; - } - seen.add(normalizedDirectory); - deduplicated.push({ scope: entry.scope, directory: normalizedDirectory }); - } - - return deduplicated; -} - -/** - * Deduplicates skills by provider + invocation command. - */ -function deduplicateSkills(skills: UnifiedSkill[]): UnifiedSkill[] { - const seen = new Set(); - const deduplicated: UnifiedSkill[] = []; - for (const skill of skills) { - const key = `${skill.provider}:${skill.invocation}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - deduplicated.push(skill); - } - - return deduplicated; -} - -/** - * Tests whether a path exists. - */ -async function pathExists(targetPath: string): Promise { - try { - await access(targetPath); - return true; - } catch { - return false; - } -} diff --git a/server/src/modules/llm/tests/llm-unifier.mcp.test.ts b/server/src/modules/llm/tests/llm-unifier.mcp.test.ts index a18834c0..b88aa59a 100644 --- a/server/src/modules/llm/tests/llm-unifier.mcp.test.ts +++ b/server/src/modules/llm/tests/llm-unifier.mcp.test.ts @@ -8,7 +8,7 @@ import test from 'node:test'; import TOML from '@iarna/toml'; import { AppError } from '@/shared/utils/app-error.js'; -import { llmMcpService } from '@/modules/llm/services/mcp.service.js'; +import { llmService } from '@/modules/llm/services/llm.service.js'; const patchHomeDir = (nextHomeDir: string) => { const original = os.homedir; @@ -27,14 +27,14 @@ const readJson = async (filePath: string): Promise> => { * 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('llmMcpService handles claude MCP scopes/transports with file-backed persistence', { concurrency: false }, async () => { +test('llmService 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 llmMcpService.upsertProviderServer('claude', { + await llmService.upsertProviderMcpServer('claude', { name: 'claude-user-stdio', scope: 'user', transport: 'stdio', @@ -43,7 +43,7 @@ test('llmMcpService handles claude MCP scopes/transports with file-backed persis env: { API_KEY: 'secret' }, }); - await llmMcpService.upsertProviderServer('claude', { + await llmService.upsertProviderMcpServer('claude', { name: 'claude-local-http', scope: 'local', transport: 'http', @@ -52,7 +52,7 @@ test('llmMcpService handles claude MCP scopes/transports with file-backed persis workspacePath, }); - await llmMcpService.upsertProviderServer('claude', { + await llmService.upsertProviderMcpServer('claude', { name: 'claude-project-sse', scope: 'project', transport: 'sse', @@ -61,13 +61,13 @@ test('llmMcpService handles claude MCP scopes/transports with file-backed persis workspacePath, }); - const grouped = await llmMcpService.listProviderServers('claude', { workspacePath }); + const grouped = await llmService.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 llmMcpService.upsertProviderServer('claude', { + await llmService.upsertProviderMcpServer('claude', { name: 'claude-project-sse', scope: 'project', transport: 'sse', @@ -81,7 +81,7 @@ test('llmMcpService handles claude MCP scopes/transports with file-backed persis const projectServer = projectServers['claude-project-sse'] as Record; assert.equal(projectServer.url, 'https://example.com/sse-updated'); - const removeResult = await llmMcpService.removeProviderServer('claude', { + const removeResult = await llmService.removeProviderMcpServer('claude', { name: 'claude-local-http', scope: 'local', workspacePath, @@ -97,14 +97,14 @@ test('llmMcpService handles claude MCP scopes/transports with file-backed persis * This test covers Codex MCP support for user/project scopes, stdio/http formats, * and validation for unsupported scope/transport combinations. */ -test('llmMcpService handles codex MCP TOML config and capability validation', { concurrency: false }, async () => { +test('llmService 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 llmMcpService.upsertProviderServer('codex', { + await llmService.upsertProviderMcpServer('codex', { name: 'codex-user-stdio', scope: 'user', transport: 'stdio', @@ -115,7 +115,7 @@ test('llmMcpService handles codex MCP TOML config and capability validation', { cwd: '/tmp', }); - await llmMcpService.upsertProviderServer('codex', { + await llmService.upsertProviderMcpServer('codex', { name: 'codex-project-http', scope: 'project', transport: 'http', @@ -139,7 +139,7 @@ test('llmMcpService handles codex MCP TOML config and capability validation', { assert.equal(projectHttp.url, 'https://codex.example.com/mcp'); await assert.rejects( - llmMcpService.upsertProviderServer('codex', { + llmService.upsertProviderMcpServer('codex', { name: 'codex-local', scope: 'local', transport: 'stdio', @@ -152,7 +152,7 @@ test('llmMcpService handles codex MCP TOML config and capability validation', { ); await assert.rejects( - llmMcpService.upsertProviderServer('codex', { + llmService.upsertProviderMcpServer('codex', { name: 'codex-sse', scope: 'project', transport: 'sse', @@ -173,14 +173,14 @@ test('llmMcpService handles codex MCP TOML config and capability validation', { /** * This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence. */ -test('llmMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => { +test('llmService 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 llmMcpService.upsertProviderServer('gemini', { + await llmService.upsertProviderMcpServer('gemini', { name: 'gemini-stdio', scope: 'user', transport: 'stdio', @@ -190,7 +190,7 @@ test('llmMcpService handles gemini and cursor MCP JSON config formats', { concur cwd: './server', }); - await llmMcpService.upsertProviderServer('gemini', { + await llmService.upsertProviderMcpServer('gemini', { name: 'gemini-http', scope: 'project', transport: 'http', @@ -199,7 +199,7 @@ test('llmMcpService handles gemini and cursor MCP JSON config formats', { concur workspacePath, }); - await llmMcpService.upsertProviderServer('cursor', { + await llmService.upsertProviderMcpServer('cursor', { name: 'cursor-stdio', scope: 'project', transport: 'stdio', @@ -209,7 +209,7 @@ test('llmMcpService handles gemini and cursor MCP JSON config formats', { concur workspacePath, }); - await llmMcpService.upsertProviderServer('cursor', { + await llmService.upsertProviderMcpServer('cursor', { name: 'cursor-http', scope: 'user', transport: 'http', @@ -240,14 +240,14 @@ test('llmMcpService handles gemini and cursor MCP JSON config formats', { concur * This test covers the global MCP adder requirement: only http/stdio are allowed and * one payload is written to all providers. */ -test('llmMcpService global adder writes to all providers and rejects unsupported transports', { concurrency: false }, async () => { +test('llmService 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 llmMcpService.addServerToAllProviders({ + const globalResult = await llmService.addMcpServerToAllProviders({ name: 'global-http', scope: 'project', transport: 'http', @@ -271,7 +271,7 @@ test('llmMcpService global adder writes to all providers and rejects unsupported assert.ok((cursorProject.mcpServers as Record)['global-http']); await assert.rejects( - llmMcpService.addServerToAllProviders({ + llmService.addMcpServerToAllProviders({ name: 'global-sse', scope: 'project', transport: 'sse', @@ -292,7 +292,7 @@ test('llmMcpService global adder writes to all providers and rejects unsupported /** * This test covers "run" behavior for both stdio and http MCP servers. */ -test('llmMcpService runProviderServer probes stdio and http MCP servers', { concurrency: false }, async () => { +test('llmService 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 }); @@ -309,7 +309,7 @@ test('llmMcpService runProviderServer probes stdio and http MCP servers', { conc assert.ok(address && typeof address === 'object'); const url = `http://127.0.0.1:${address.port}/mcp`; - await llmMcpService.upsertProviderServer('gemini', { + await llmService.upsertProviderMcpServer('gemini', { name: 'probe-http', scope: 'project', transport: 'http', @@ -317,7 +317,7 @@ test('llmMcpService runProviderServer probes stdio and http MCP servers', { conc workspacePath, }); - await llmMcpService.upsertProviderServer('cursor', { + await llmService.upsertProviderMcpServer('cursor', { name: 'probe-stdio', scope: 'project', transport: 'stdio', @@ -326,8 +326,7 @@ test('llmMcpService runProviderServer probes stdio and http MCP servers', { conc workspacePath, }); - const httpProbe = await llmMcpService.runProviderServer({ - provider: 'gemini', + const httpProbe = await llmService.runProviderMcpServer('gemini', { name: 'probe-http', scope: 'project', workspacePath, @@ -335,8 +334,7 @@ test('llmMcpService runProviderServer probes stdio and http MCP servers', { conc assert.equal(httpProbe.reachable, true); assert.equal(httpProbe.transport, 'http'); - const stdioProbe = await llmMcpService.runProviderServer({ - provider: 'cursor', + const stdioProbe = await llmService.runProviderMcpServer('cursor', { name: 'probe-stdio', scope: 'project', workspacePath, diff --git a/server/src/modules/llm/tests/llm-unifier.skills.test.ts b/server/src/modules/llm/tests/llm-unifier.skills.test.ts index 5ad0d0cb..6bad1837 100644 --- a/server/src/modules/llm/tests/llm-unifier.skills.test.ts +++ b/server/src/modules/llm/tests/llm-unifier.skills.test.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { llmSkillsService } from '@/modules/llm/services/skills.service.js'; +import { llmService } from '@/modules/llm/services/llm.service.js'; const patchHomeDir = (nextHomeDir: string) => { const original = os.homedir; @@ -34,7 +34,7 @@ const createSkill = async ( /** * This test covers Claude skills fetching from user/project/plugin locations and plugin namespace invocation. */ -test('llmSkillsService lists claude user/project/plugin skills with proper invocation names', { concurrency: false }, async () => { +test('llmService lists claude user/project/plugin skills with proper invocation names', { concurrency: false }, async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-claude-')); const workspacePath = path.join(tempRoot, 'workspace'); const pluginInstallPath = path.join(tempRoot, 'plugin-install'); @@ -80,7 +80,7 @@ test('llmSkillsService lists claude user/project/plugin skills with proper invoc 'utf8', ); - const skills = await llmSkillsService.listProviderSkills('claude', { workspacePath }); + const skills = await llmService.listProviderSkills('claude', { workspacePath }); assert.ok(skills.some((skill) => skill.scope === 'user' && skill.invocation === '/user-helper')); assert.ok(skills.some((skill) => skill.scope === 'project' && skill.invocation === '/project-helper')); assert.ok(skills.some((skill) => skill.scope === 'plugin' && skill.invocation === '/example-skills:plugin-helper')); @@ -93,7 +93,7 @@ test('llmSkillsService lists claude user/project/plugin skills with proper invoc /** * This test covers Codex skills discovery across repo/user/system locations and `$` invocation prefix. */ -test('llmSkillsService lists codex skills from repo/user/system locations with dollar invocation', { concurrency: false }, async () => { +test('llmService lists codex skills from repo/user/system locations with dollar invocation', { concurrency: false }, async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-codex-')); const repoRoot = path.join(tempRoot, 'repo'); const workspacePath = path.join(repoRoot, 'packages', 'app'); @@ -123,7 +123,7 @@ test('llmSkillsService lists codex skills from repo/user/system locations with d description: 'system skill', }); - const skills = await llmSkillsService.listProviderSkills('codex', { workspacePath }); + const skills = await llmService.listProviderSkills('codex', { workspacePath }); assert.ok(skills.some((skill) => skill.name === 'cwd-skill' && skill.invocation === '$cwd-skill')); assert.ok(skills.some((skill) => skill.name === 'parent-skill' && skill.invocation === '$parent-skill')); assert.ok(skills.some((skill) => skill.name === 'repo-root-skill' && skill.invocation === '$repo-root-skill')); @@ -138,7 +138,7 @@ test('llmSkillsService lists codex skills from repo/user/system locations with d /** * This test covers Gemini skill fetch locations and slash-based invocation format. */ -test('llmSkillsService lists gemini skills from documented directories', { concurrency: false }, async () => { +test('llmService lists gemini skills from documented directories', { concurrency: false }, async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gemini-')); const workspacePath = path.join(tempRoot, 'workspace'); await fs.mkdir(workspacePath, { recursive: true }); @@ -162,7 +162,7 @@ test('llmSkillsService lists gemini skills from documented directories', { concu description: 'project agents skill', }); - const skills = await llmSkillsService.listProviderSkills('gemini', { workspacePath }); + const skills = await llmService.listProviderSkills('gemini', { workspacePath }); assert.ok(skills.some((skill) => skill.invocation === '/home-gemini')); assert.ok(skills.some((skill) => skill.invocation === '/home-agents')); assert.ok(skills.some((skill) => skill.invocation === '/project-gemini')); @@ -176,7 +176,7 @@ test('llmSkillsService lists gemini skills from documented directories', { concu /** * This test covers Cursor skill fetch locations and slash-based invocation format. */ -test('llmSkillsService lists cursor skills from documented directories', { concurrency: false }, async () => { +test('llmService lists cursor skills from documented directories', { concurrency: false }, async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-cursor-')); const workspacePath = path.join(tempRoot, 'workspace'); await fs.mkdir(workspacePath, { recursive: true }); @@ -196,7 +196,7 @@ test('llmSkillsService lists cursor skills from documented directories', { concu description: 'user cursor skill', }); - const skills = await llmSkillsService.listProviderSkills('cursor', { workspacePath }); + const skills = await llmService.listProviderSkills('cursor', { workspacePath }); assert.ok(skills.some((skill) => skill.invocation === '/project-agents')); assert.ok(skills.some((skill) => skill.invocation === '/project-cursor')); assert.ok(skills.some((skill) => skill.invocation === '/user-cursor'));