mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 01:12:46 +00:00
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.
This commit is contained in:
@@ -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<string, unknown> : {}),
|
||||
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<string, unknown> | 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 }),
|
||||
])),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<string, MutableProviderSession>();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CodexSdkClient> | null = null;
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared payload shape for MCP server create/update operations.
|
||||
*/
|
||||
export type UpsertProviderMcpServerInput = {
|
||||
name: string;
|
||||
scope?: McpScope;
|
||||
transport: McpTransport;
|
||||
workspacePath?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<ProviderModel[]>;
|
||||
|
||||
@@ -89,6 +148,36 @@ export interface IProvider {
|
||||
listSessions(): ProviderSessionSnapshot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP runtime contract for one provider.
|
||||
*/
|
||||
export interface IProviderMcpRuntime {
|
||||
listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>>;
|
||||
listServersForScope(scope: McpScope, options?: { workspacePath?: string }): Promise<ProviderMcpServer[]>;
|
||||
upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer>;
|
||||
removeServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }>;
|
||||
runServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
transport: McpTransport;
|
||||
reachable: boolean;
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills runtime contract for one provider.
|
||||
*/
|
||||
export interface IProviderSkillsRuntime {
|
||||
listSkills(options?: { workspacePath?: string }): Promise<ProviderSkill[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal mutable session state used by provider base classes.
|
||||
*/
|
||||
|
||||
@@ -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<Record<McpScope, ProviderMcpServer[]>> {
|
||||
const grouped: Record<McpScope, ProviderMcpServer[]> = {
|
||||
user: [],
|
||||
local: [],
|
||||
project: [],
|
||||
};
|
||||
|
||||
for (const scope of this.supportedScopes) {
|
||||
grouped[scope] = await this.listServersForScope(scope, options);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists MCP servers for one scope.
|
||||
*/
|
||||
async listServersForScope(
|
||||
scope: McpScope,
|
||||
options?: { workspacePath?: string },
|
||||
): Promise<ProviderMcpServer[]> {
|
||||
if (!this.supportedScopes.includes(scope)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspacePath = resolveWorkspacePath(options?.workspacePath);
|
||||
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
||||
return Object.entries(scopedServers)
|
||||
.map(([name, rawConfig]) => this.normalizeServerConfig(scope, name, rawConfig))
|
||||
.filter((entry): entry is ProviderMcpServer => entry !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or updates one MCP server.
|
||||
*/
|
||||
async upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer> {
|
||||
const scope = input.scope ?? 'project';
|
||||
this.assertScopeAndTransport(scope, input.transport);
|
||||
|
||||
const workspacePath = resolveWorkspacePath(input.workspacePath);
|
||||
const normalizedName = normalizeServerName(input.name);
|
||||
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
||||
scopedServers[normalizedName] = this.buildServerConfig(input);
|
||||
await this.writeScopedServers(scope, workspacePath, scopedServers);
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
name: normalizedName,
|
||||
scope,
|
||||
transport: input.transport,
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
env: input.env,
|
||||
cwd: input.cwd,
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
envVars: input.envVars,
|
||||
bearerTokenEnvVar: input.bearerTokenEnvVar,
|
||||
envHttpHeaders: input.envHttpHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Persists one scope's raw server map back to provider-native files.
|
||||
*/
|
||||
protected abstract writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Creates one provider-native config object from a unified input payload.
|
||||
*/
|
||||
protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
154
server/src/modules/llm/providers/runtimes/claude-mcp.runtime.ts
Normal file
154
server/src/modules/llm/providers/runtimes/claude-mcp.runtime.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||
if (scope === 'project') {
|
||||
const filePath = path.join(workspacePath, '.mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
const filePath = path.join(os.homedir(), '.claude.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
if (scope === 'user') {
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
const projects = readObjectRecord(config.projects) ?? {};
|
||||
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
|
||||
return readObjectRecord(projectConfig.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes Claude MCP servers to user/local/project config locations.
|
||||
*/
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (scope === 'project') {
|
||||
const filePath = path.join(workspacePath, '.mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(os.homedir(), '.claude.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
if (scope === 'user') {
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = readObjectRecord(config.projects) ?? {};
|
||||
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
|
||||
projectConfig.mcpServers = servers;
|
||||
projects[workspacePath] = projectConfig;
|
||||
config.projects = projects;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one Claude-native server object from the unified input payload.
|
||||
*/
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'stdio',
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'claude',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
|
||||
return {
|
||||
provider: 'claude',
|
||||
name,
|
||||
scope,
|
||||
transport,
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderSkill[]> {
|
||||
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<string, unknown>).installPath === 'string'
|
||||
? (install as Record<string, unknown>).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<string[]> {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
try {
|
||||
const settingsContent = await readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsContent) as Record<string, unknown>;
|
||||
const enabledPlugins = settings.enabledPlugins;
|
||||
if (!enabledPlugins || typeof enabledPlugins !== 'object' || Array.isArray(enabledPlugins)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enabledRecords = enabledPlugins as Record<string, unknown>;
|
||||
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<Record<string, unknown[]>> {
|
||||
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<string, unknown>;
|
||||
const plugins = index.plugins;
|
||||
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown[]> = {};
|
||||
for (const [pluginId, entries] of Object.entries(plugins as Record<string, unknown>)) {
|
||||
normalized[pluginId] = Array.isArray(entries) ? entries : [];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
133
server/src/modules/llm/providers/runtimes/codex-mcp.runtime.ts
Normal file
133
server/src/modules/llm/providers/runtimes/codex-mcp.runtime.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.codex', 'config.toml')
|
||||
: path.join(workspacePath, '.codex', 'config.toml');
|
||||
const config = await readTomlConfig(filePath);
|
||||
return readObjectRecord(config.mcp_servers) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes Codex MCP servers to user/project config.toml scopes.
|
||||
*/
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.codex', 'config.toml')
|
||||
: path.join(workspacePath, '.codex', 'config.toml');
|
||||
const config = await readTomlConfig(filePath);
|
||||
config.mcp_servers = servers;
|
||||
await writeTomlConfig(filePath, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one Codex-native server object from the unified input payload.
|
||||
*/
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
env_vars: input.envVars ?? [],
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url: input.url,
|
||||
bearer_token_env_var: input.bearerTokenEnvVar,
|
||||
http_headers: input.headers ?? {},
|
||||
env_http_headers: input.envHttpHeaders ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'codex',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
envVars: readStringArray(config.env_vars),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
return {
|
||||
provider: 'codex',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.http_headers),
|
||||
bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var),
|
||||
envHttpHeaders: readStringRecord(config.env_http_headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderSkill[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
127
server/src/modules/llm/providers/runtimes/cursor-mcp.runtime.ts
Normal file
127
server/src/modules/llm/providers/runtimes/cursor-mcp.runtime.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.cursor', 'mcp.json')
|
||||
: path.join(workspacePath, '.cursor', 'mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes Cursor MCP servers to user/project config files.
|
||||
*/
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.cursor', 'mcp.json')
|
||||
: path.join(workspacePath, '.cursor', 'mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one Cursor-native server object from the unified input payload.
|
||||
*/
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'cursor',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
return {
|
||||
provider: 'cursor',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderSkill[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
129
server/src/modules/llm/providers/runtimes/gemini-mcp.runtime.ts
Normal file
129
server/src/modules/llm/providers/runtimes/gemini-mcp.runtime.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes Gemini MCP servers to user/project config files.
|
||||
*/
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one Gemini-native server object from the unified input payload.
|
||||
*/
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport,
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderSkill[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
207
server/src/modules/llm/providers/runtimes/mcp-runtime.utils.ts
Normal file
207
server/src/modules/llm/providers/runtimes/mcp-runtime.utils.ts
Normal file
@@ -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<string, unknown> | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string, string> | undefined => {
|
||||
const record = readObjectRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
if (typeof entry === 'string') {
|
||||
normalized[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely reads a JSON config file and returns an empty object when missing.
|
||||
*/
|
||||
export const readJsonConfig = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes one JSON config with stable formatting.
|
||||
*/
|
||||
export const writeJsonConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely reads a TOML config and returns an empty object when missing.
|
||||
*/
|
||||
export const readTomlConfig = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = TOML.parse(content) as Record<string, unknown>;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes one TOML config file.
|
||||
*/
|
||||
export const writeTomlConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
|
||||
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',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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<boolean> => {
|
||||
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 `<skills-dir>/<skill-name>/SKILL.md` directory layout.
|
||||
*/
|
||||
export const listSkillsFromDirectory = async (input: {
|
||||
provider: LLMProvider;
|
||||
scope: ProviderSkillScope;
|
||||
skillsDirectory: string;
|
||||
invocationPrefix: '/' | '$';
|
||||
pluginName?: string;
|
||||
}): Promise<ProviderSkill[]> => {
|
||||
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<string | null> => {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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;
|
||||
};
|
||||
@@ -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<Record<McpScope, ProviderMcpServer[]>> {
|
||||
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<ProviderMcpServer[]> {
|
||||
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<ProviderMcpServer> {
|
||||
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<UpsertProviderMcpServerInput, 'scope'> & { scope?: Exclude<McpScope, 'local'> },
|
||||
): Promise<Array<{ provider: LLMProvider; created: boolean; error?: string }>> {
|
||||
if (input.transport !== 'stdio' && input.transport !== 'http') {
|
||||
throw new AppError('Global MCP add supports only "stdio" and "http".', {
|
||||
code: 'INVALID_GLOBAL_MCP_TRANSPORT',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const scope = input.scope ?? 'project';
|
||||
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
|
||||
const providers = 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<ProviderSkill[]> {
|
||||
const provider = llmProviderRegistry.resolveProvider(providerName);
|
||||
return provider.skills.listSkills(options);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type UpsertMcpServerInput = {
|
||||
name: string;
|
||||
scope?: McpScope;
|
||||
transport: McpTransport;
|
||||
workspacePath?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
const PROVIDER_CAPABILITIES: Record<LLMProvider, { scopes: McpScope[]; transports: McpTransport[] }> = {
|
||||
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<Record<McpScope, UnifiedMcpServer[]>> {
|
||||
const workspacePath = resolveWorkspacePath(options?.workspacePath);
|
||||
const grouped: Record<McpScope, UnifiedMcpServer[]> = {
|
||||
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<UnifiedMcpServer> {
|
||||
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<UpsertMcpServerInput, 'scope'> & { scope?: Exclude<McpScope, 'local'> },
|
||||
): Promise<Array<{ provider: LLMProvider; created: boolean; error?: string }>> {
|
||||
if (input.transport !== 'stdio' && input.transport !== 'http') {
|
||||
throw new AppError('Global MCP add supports only "stdio" and "http".', {
|
||||
code: 'INVALID_GLOBAL_MCP_TRANSPORT',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const scope = input.scope ?? 'project';
|
||||
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
|
||||
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<UnifiedMcpServer[]> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>,
|
||||
): Promise<void> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
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<Record<string, unknown>> {
|
||||
if (scope === 'project') {
|
||||
const filePath = path.join(workspacePath, '.mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
const filePath = path.join(os.homedir(), '.claude.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
if (scope === 'user') {
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (scope === 'project') {
|
||||
const filePath = path.join(workspacePath, '.mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(os.homedir(), '.claude.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
if (scope === 'user') {
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = readObjectRecord(config.projects) ?? {};
|
||||
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
|
||||
projectConfig.mcpServers = servers;
|
||||
projects[workspacePath] = projectConfig;
|
||||
config.projects = projects;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Codex MCP servers from config.toml user or project scopes.
|
||||
*/
|
||||
async function readCodexScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>,
|
||||
): Promise<void> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>,
|
||||
): Promise<void> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>,
|
||||
): Promise<void> {
|
||||
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<Record<string, unknown>> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one JSON config with stable formatting.
|
||||
*/
|
||||
async function writeJsonConfig(filePath: string, data: Record<string, unknown>): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely reads a TOML config and returns an empty object when missing.
|
||||
*/
|
||||
async function readTomlConfig(filePath: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = TOML.parse(content) as Record<string, unknown>;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one TOML config file.
|
||||
*/
|
||||
async function writeTomlConfig(filePath: string, data: Record<string, unknown>): Promise<void> {
|
||||
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<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string> | undefined {
|
||||
const record = readObjectRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
if (typeof entry === 'string') {
|
||||
normalized[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
@@ -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<UnifiedSkill[]> {
|
||||
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<UnifiedSkill[]> {
|
||||
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<string, unknown>).installPath === 'string'
|
||||
? (install as Record<string, unknown>).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<UnifiedSkill[]> {
|
||||
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<UnifiedSkill[]> {
|
||||
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<UnifiedSkill[]> {
|
||||
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 `<skills-dir>/<skill-name>/SKILL.md` directory layout.
|
||||
*/
|
||||
async function listSkillsFromDirectory(input: {
|
||||
provider: LLMProvider;
|
||||
scope: SkillScope;
|
||||
skillsDirectory: string;
|
||||
invocationPrefix: '/' | '$';
|
||||
pluginName?: string;
|
||||
}): Promise<UnifiedSkill[]> {
|
||||
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<string[]> {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
try {
|
||||
const settingsContent = await readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsContent) as Record<string, unknown>;
|
||||
const enabledPlugins = settings.enabledPlugins;
|
||||
if (!enabledPlugins || typeof enabledPlugins !== 'object' || Array.isArray(enabledPlugins)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enabledRecords = enabledPlugins as Record<string, unknown>;
|
||||
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<Record<string, unknown[]>> {
|
||||
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<string, unknown>;
|
||||
const plugins = index.plugins;
|
||||
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown[]> = {};
|
||||
for (const [pluginId, entries] of Object.entries(plugins as Record<string, unknown>)) {
|
||||
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<string | null> {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<boolean> {
|
||||
try {
|
||||
await access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, unknown>> => {
|
||||
* This test covers Claude MCP support for all scopes (user/local/project) and all transports (stdio/http/sse),
|
||||
* including add, update/list, and remove operations.
|
||||
*/
|
||||
test('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<string, unknown>;
|
||||
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<string, unknown>)['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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user