feat(extensions): add provider-aware runtime adapters
This commit is contained in:
parent
1de59cb84f
commit
096437b2fd
25 changed files with 768 additions and 225 deletions
|
|
@ -84,6 +84,7 @@ import {
|
||||||
SkillsCatalogService,
|
SkillsCatalogService,
|
||||||
SkillsMutationService,
|
SkillsMutationService,
|
||||||
SkillsWatcherService,
|
SkillsWatcherService,
|
||||||
|
createExtensionsRuntimeAdapter,
|
||||||
} from './services/extensions';
|
} from './services/extensions';
|
||||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
|
|
@ -871,8 +872,9 @@ async function initializeServices(): Promise<void> {
|
||||||
const officialMcpRegistry = new OfficialMcpRegistryService();
|
const officialMcpRegistry = new OfficialMcpRegistryService();
|
||||||
const glamaMcpService = new GlamaMcpEnrichmentService();
|
const glamaMcpService = new GlamaMcpEnrichmentService();
|
||||||
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
||||||
const mcpStateService = new McpInstallationStateService();
|
const extensionsRuntimeAdapter = createExtensionsRuntimeAdapter();
|
||||||
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService();
|
const mcpStateService = new McpInstallationStateService(extensionsRuntimeAdapter);
|
||||||
|
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(extensionsRuntimeAdapter);
|
||||||
const skillsCatalogService = new SkillsCatalogService();
|
const skillsCatalogService = new SkillsCatalogService();
|
||||||
const skillsMutationService = new SkillsMutationService();
|
const skillsMutationService = new SkillsMutationService();
|
||||||
skillsWatcherService = new SkillsWatcherService();
|
skillsWatcherService = new SkillsWatcherService();
|
||||||
|
|
@ -884,8 +886,11 @@ async function initializeServices(): Promise<void> {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Install services — resolve binary dynamically via ClaudeBinaryResolver
|
// Install services — resolve binary dynamically via ClaudeBinaryResolver
|
||||||
const pluginInstallService = new PluginInstallService(pluginCatalogService);
|
const pluginInstallService = new PluginInstallService(
|
||||||
const mcpInstallService = new McpInstallService(mcpAggregator);
|
pluginCatalogService,
|
||||||
|
extensionsRuntimeAdapter
|
||||||
|
);
|
||||||
|
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||||
// warmup() and ensureInstalled() are deferred to after window creation
|
// warmup() and ensureInstalled() are deferred to after window creation
|
||||||
|
|
|
||||||
|
|
@ -239,8 +239,13 @@ function getMcpHealthDiagnostics(): McpHealthDiagnosticsService {
|
||||||
return mcpHealthDiagnostics;
|
return mcpHealthDiagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMcpDiagnose(): Promise<IpcResult<McpServerDiagnostic[]>> {
|
async function handleMcpDiagnose(
|
||||||
return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose());
|
_event: IpcMainInvokeEvent,
|
||||||
|
projectPath?: string
|
||||||
|
): Promise<IpcResult<McpServerDiagnostic[]>> {
|
||||||
|
return wrapHandler('mcpDiagnose', () =>
|
||||||
|
getMcpHealthDiagnostics().diagnose(typeof projectPath === 'string' ? projectPath : undefined)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Install/Uninstall Handlers ────────────────────────────────────────────
|
// ── Install/Uninstall Handlers ────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ export { SkillsCatalogService } from './skills/SkillsCatalogService';
|
||||||
export { SkillsMutationService } from './skills/SkillsMutationService';
|
export { SkillsMutationService } from './skills/SkillsMutationService';
|
||||||
export { SkillsWatcherService } from './skills/SkillsWatcherService';
|
export { SkillsWatcherService } from './skills/SkillsWatcherService';
|
||||||
export { SkillValidator } from './skills/SkillValidator';
|
export { SkillValidator } from './skills/SkillValidator';
|
||||||
|
export {
|
||||||
|
ClaudeExtensionsAdapter,
|
||||||
|
createExtensionsRuntimeAdapter,
|
||||||
|
MultimodelExtensionsAdapter,
|
||||||
|
} from './runtime/ExtensionsRuntimeAdapter';
|
||||||
export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService';
|
export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService';
|
||||||
export { McpInstallationStateService } from './state/McpInstallationStateService';
|
export { McpInstallationStateService } from './state/McpInstallationStateService';
|
||||||
export { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
export { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@
|
||||||
|
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
|
|
||||||
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
||||||
|
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import type {
|
import type {
|
||||||
McpCustomInstallRequest,
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
|
|
@ -42,7 +44,10 @@ function scopeRequiresProjectPath(scope?: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class McpInstallService {
|
export class McpInstallService {
|
||||||
constructor(private readonly aggregator: McpCatalogAggregator) {}
|
constructor(
|
||||||
|
private readonly aggregator: McpCatalogAggregator,
|
||||||
|
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||||
|
) {}
|
||||||
|
|
||||||
async install(request: McpInstallRequest): Promise<OperationResult> {
|
async install(request: McpInstallRequest): Promise<OperationResult> {
|
||||||
const { registryId, serverName, scope, projectPath, envValues, headers } = request;
|
const { registryId, serverName, scope, projectPath, envValues, headers } = request;
|
||||||
|
|
@ -180,11 +185,12 @@ export class McpInstallService {
|
||||||
error: CLI_NOT_FOUND_MESSAGE,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
const { stderr } = await execCli(claudeBinary, args, {
|
const { stderr } = await execCli(claudeBinary, args, {
|
||||||
timeout: TIMEOUT_MS,
|
timeout: TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
|
|
@ -295,11 +301,12 @@ export class McpInstallService {
|
||||||
error: CLI_NOT_FOUND_MESSAGE,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
const { stderr } = await execCli(claudeBinary, args, {
|
const { stderr } = await execCli(claudeBinary, args, {
|
||||||
timeout: TIMEOUT_MS,
|
timeout: TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
|
|
@ -364,11 +371,12 @@ export class McpInstallService {
|
||||||
error: CLI_NOT_FOUND_MESSAGE,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
await execCli(claudeBinary, args, {
|
await execCli(claudeBinary, args, {
|
||||||
timeout: TIMEOUT_MS,
|
timeout: TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
return { state: 'success' };
|
return { state: 'success' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@
|
||||||
|
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
|
|
||||||
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
|
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
|
||||||
|
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:PluginInstall');
|
const logger = createLogger('Extensions:PluginInstall');
|
||||||
|
|
@ -31,7 +33,10 @@ function scopeRequiresProjectPath(scope?: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginInstallService {
|
export class PluginInstallService {
|
||||||
constructor(private readonly catalogService: PluginCatalogService) {}
|
constructor(
|
||||||
|
private readonly catalogService: PluginCatalogService,
|
||||||
|
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||||
|
) {}
|
||||||
|
|
||||||
async install(request: PluginInstallRequest): Promise<OperationResult> {
|
async install(request: PluginInstallRequest): Promise<OperationResult> {
|
||||||
const { pluginId, scope, projectPath } = request;
|
const { pluginId, scope, projectPath } = request;
|
||||||
|
|
@ -95,11 +100,12 @@ export class PluginInstallService {
|
||||||
error: CLI_NOT_FOUND_MESSAGE,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
const { stdout, stderr } = await execCli(claudeBinary, args, {
|
const { stdout, stderr } = await execCli(claudeBinary, args, {
|
||||||
timeout: INSTALL_TIMEOUT_MS,
|
timeout: INSTALL_TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stderr && !stdout) {
|
if (stderr && !stdout) {
|
||||||
|
|
@ -175,11 +181,12 @@ export class PluginInstallService {
|
||||||
error: CLI_NOT_FOUND_MESSAGE,
|
error: CLI_NOT_FOUND_MESSAGE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||||
|
|
||||||
await execCli(claudeBinary, args, {
|
await execCli(claudeBinary, args, {
|
||||||
timeout: UNINSTALL_TIMEOUT_MS,
|
timeout: UNINSTALL_TIMEOUT_MS,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
env,
|
||||||
});
|
});
|
||||||
return { state: 'success' };
|
return { state: 'success' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
134
src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts
Normal file
134
src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
|
import { getConfiguredCliFlavor } from '@main/services/team/cliFlavor';
|
||||||
|
import { execCli } from '@main/utils/childProcess';
|
||||||
|
import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv';
|
||||||
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
|
|
||||||
|
import { McpConfigStateReader } from './McpConfigStateReader';
|
||||||
|
import { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput } from './mcpDiagnosticsParser';
|
||||||
|
import { parseInstalledMcpJsonOutput } from './mcpRuntimeJson';
|
||||||
|
|
||||||
|
import type { CliFlavor } from '@shared/types';
|
||||||
|
import type { InstalledMcpEntry, McpServerDiagnostic } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
const MCP_LIST_TIMEOUT_MS = 15_000;
|
||||||
|
const MCP_DIAGNOSE_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export interface ExtensionsRuntimeAdapter {
|
||||||
|
readonly flavor: CliFlavor;
|
||||||
|
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;
|
||||||
|
getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]>;
|
||||||
|
diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClaudeExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
||||||
|
readonly flavor = 'claude' as const;
|
||||||
|
|
||||||
|
constructor(private readonly stateReader = new McpConfigStateReader()) {}
|
||||||
|
|
||||||
|
async buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
|
||||||
|
const { env } = await buildProviderAwareCliEnv({
|
||||||
|
binaryPath,
|
||||||
|
connectionMode: 'augment',
|
||||||
|
});
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||||
|
return this.stateReader.readInstalled(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]> {
|
||||||
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
|
if (!binaryPath) {
|
||||||
|
throw new Error(CLI_NOT_FOUND_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = await this.buildManagementCliEnv(binaryPath);
|
||||||
|
const { stdout, stderr } = await execCli(binaryPath, ['mcp', 'list'], {
|
||||||
|
timeout: MCP_DIAGNOSE_TIMEOUT_MS,
|
||||||
|
cwd: projectPath,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseMcpDiagnosticsOutput([stdout, stderr].filter(Boolean).join('\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
||||||
|
readonly flavor = 'agent_teams_orchestrator' as const;
|
||||||
|
|
||||||
|
async buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
|
||||||
|
const { env } = await buildProviderAwareCliEnv({
|
||||||
|
binaryPath,
|
||||||
|
connectionMode: 'augment',
|
||||||
|
});
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||||
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
|
if (!binaryPath) {
|
||||||
|
throw new Error(CLI_NOT_FOUND_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = await this.buildManagementCliEnv(binaryPath);
|
||||||
|
const { stdout } = await execCli(binaryPath, ['mcp', 'list', '--json'], {
|
||||||
|
timeout: MCP_LIST_TIMEOUT_MS,
|
||||||
|
cwd: projectPath,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseInstalledMcpJsonOutput(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
async diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]> {
|
||||||
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
|
if (!binaryPath) {
|
||||||
|
throw new Error(CLI_NOT_FOUND_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = await this.buildManagementCliEnv(binaryPath);
|
||||||
|
const { stdout } = await execCli(binaryPath, ['mcp', 'diagnose', '--json'], {
|
||||||
|
timeout: MCP_DIAGNOSE_TIMEOUT_MS,
|
||||||
|
cwd: projectPath,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseMcpDiagnosticsJsonOutput(stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RuntimeSwitchingExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
||||||
|
constructor(
|
||||||
|
private readonly claudeAdapter: ClaudeExtensionsAdapter,
|
||||||
|
private readonly multimodelAdapter: MultimodelExtensionsAdapter
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private getActiveAdapter(): ExtensionsRuntimeAdapter {
|
||||||
|
return getConfiguredCliFlavor() === 'claude' ? this.claudeAdapter : this.multimodelAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
get flavor(): CliFlavor {
|
||||||
|
return this.getActiveAdapter().flavor;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
|
||||||
|
return this.getActiveAdapter().buildManagementCliEnv(binaryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||||
|
return this.getActiveAdapter().getInstalledMcp(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]> {
|
||||||
|
return this.getActiveAdapter().diagnoseMcp(projectPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExtensionsRuntimeAdapter(): ExtensionsRuntimeAdapter {
|
||||||
|
return new RuntimeSwitchingExtensionsAdapter(
|
||||||
|
new ClaudeExtensionsAdapter(),
|
||||||
|
new MultimodelExtensionsAdapter()
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/main/services/extensions/runtime/McpConfigStateReader.ts
Normal file
101
src/main/services/extensions/runtime/McpConfigStateReader.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
const logger = createLogger('Extensions:McpConfigStateReader');
|
||||||
|
|
||||||
|
export class McpConfigStateReader {
|
||||||
|
async readInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||||
|
const entries: InstalledMcpEntry[] = [];
|
||||||
|
const claudeConfig = await this.readClaudeConfig();
|
||||||
|
|
||||||
|
entries.push(...this.readUserMcpServers(claudeConfig));
|
||||||
|
|
||||||
|
if (projectPath) {
|
||||||
|
entries.push(...this.readLocalMcpServers(claudeConfig, projectPath));
|
||||||
|
entries.push(...(await this.readProjectMcpServers(projectPath)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readClaudeConfig(): Promise<Record<string, unknown> | null> {
|
||||||
|
const configPath = path.join(getHomeDir(), '.claude.json');
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, 'utf-8');
|
||||||
|
return JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
logger.error(`Failed to read MCP servers from ${configPath}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readUserMcpServers(config: Record<string, unknown> | null): InstalledMcpEntry[] {
|
||||||
|
return this.readMcpServersFromConfig(config?.mcpServers, 'user');
|
||||||
|
}
|
||||||
|
|
||||||
|
private readLocalMcpServers(
|
||||||
|
config: Record<string, unknown> | null,
|
||||||
|
projectPath: string
|
||||||
|
): InstalledMcpEntry[] {
|
||||||
|
const projects =
|
||||||
|
config && typeof config.projects === 'object' && config.projects
|
||||||
|
? (config.projects as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const projectConfig =
|
||||||
|
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
|
||||||
|
? (projects[projectPath] as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
|
||||||
|
const configPath = path.join(projectPath, '.mcp.json');
|
||||||
|
return this.readMcpServersFromFile(configPath, 'project');
|
||||||
|
}
|
||||||
|
|
||||||
|
private readMcpServersFromConfig(
|
||||||
|
value: unknown,
|
||||||
|
scope: 'user' | 'project' | 'local'
|
||||||
|
): InstalledMcpEntry[] {
|
||||||
|
const mcpServers =
|
||||||
|
value && typeof value === 'object'
|
||||||
|
? (value as Record<string, { command?: string; url?: string }>)
|
||||||
|
: null;
|
||||||
|
if (!mcpServers) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
|
||||||
|
let transport: string | undefined;
|
||||||
|
if (config.command) transport = 'stdio';
|
||||||
|
else if (config.url) transport = 'http';
|
||||||
|
|
||||||
|
return { name, scope, transport };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readMcpServersFromFile(
|
||||||
|
filePath: string,
|
||||||
|
scope: 'user' | 'project'
|
||||||
|
): Promise<InstalledMcpEntry[]> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const json = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
return this.readMcpServersFromConfig(json.mcpServers, scope);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
logger.error(`Failed to read MCP servers from ${filePath}:`, err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/main/services/extensions/runtime/mcpDiagnosticsParser.ts
Normal file
126
src/main/services/extensions/runtime/mcpDiagnosticsParser.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
interface McpDiagnoseJsonEntry {
|
||||||
|
name?: string;
|
||||||
|
target?: string;
|
||||||
|
status?: 'connected' | 'needs-authentication' | 'failed' | 'timeout';
|
||||||
|
statusLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpDiagnoseJsonPayload {
|
||||||
|
checkedAt?: string;
|
||||||
|
diagnostics?: McpDiagnoseJsonEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonObject<T>(raw: string): T {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as T;
|
||||||
|
} catch {
|
||||||
|
const start = trimmed.indexOf('{');
|
||||||
|
const end = trimmed.lastIndexOf('}');
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
return JSON.parse(trimmed.slice(start, end + 1)) as T;
|
||||||
|
}
|
||||||
|
throw new Error('No JSON object found in CLI output');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatusChunk(statusChunk: string): {
|
||||||
|
status: McpServerHealthStatus;
|
||||||
|
statusLabel: string;
|
||||||
|
} {
|
||||||
|
const symbol = statusChunk[0];
|
||||||
|
const label = statusChunk.slice(1).trim() || 'Unknown';
|
||||||
|
|
||||||
|
switch (symbol) {
|
||||||
|
case '✓':
|
||||||
|
return { status: 'connected', statusLabel: label };
|
||||||
|
case '!':
|
||||||
|
return { status: 'needs-authentication', statusLabel: label };
|
||||||
|
case '✗':
|
||||||
|
return { status: 'failed', statusLabel: label };
|
||||||
|
default:
|
||||||
|
return { status: 'unknown', statusLabel: statusChunk };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null {
|
||||||
|
const statusSeparatorIdx = line.lastIndexOf(' - ');
|
||||||
|
if (statusSeparatorIdx === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = line.slice(0, statusSeparatorIdx).trim();
|
||||||
|
const statusChunk = line.slice(statusSeparatorIdx + 3).trim();
|
||||||
|
|
||||||
|
const nameSeparatorIdx = descriptor.indexOf(': ');
|
||||||
|
if (nameSeparatorIdx === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = descriptor.slice(0, nameSeparatorIdx).trim();
|
||||||
|
const target = descriptor.slice(nameSeparatorIdx + 2).trim();
|
||||||
|
if (!name || !target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, statusLabel } = parseStatusChunk(statusChunk);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
target,
|
||||||
|
status,
|
||||||
|
statusLabel,
|
||||||
|
rawLine: line,
|
||||||
|
checkedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] {
|
||||||
|
const checkedAt = Date.now();
|
||||||
|
|
||||||
|
return output
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health'))
|
||||||
|
.map((line) => parseDiagnosticLine(line, checkedAt))
|
||||||
|
.filter((entry): entry is McpServerDiagnostic => entry !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnostic[] {
|
||||||
|
const parsed = extractJsonObject<McpDiagnoseJsonPayload>(output);
|
||||||
|
const checkedAtValue = parsed.checkedAt ? Date.parse(parsed.checkedAt) : Number.NaN;
|
||||||
|
const checkedAt = Number.isFinite(checkedAtValue) ? checkedAtValue : Date.now();
|
||||||
|
|
||||||
|
return (parsed.diagnostics ?? []).flatMap<McpServerDiagnostic>((entry) => {
|
||||||
|
if (
|
||||||
|
typeof entry.name !== 'string' ||
|
||||||
|
typeof entry.target !== 'string' ||
|
||||||
|
typeof entry.statusLabel !== 'string'
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStatus: McpServerHealthStatus =
|
||||||
|
entry.status === 'connected'
|
||||||
|
? 'connected'
|
||||||
|
: entry.status === 'needs-authentication'
|
||||||
|
? 'needs-authentication'
|
||||||
|
: entry.status === 'failed' || entry.status === 'timeout'
|
||||||
|
? 'failed'
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
|
const rawLine = `${entry.name}: ${entry.target} - ${entry.statusLabel}`;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: entry.name,
|
||||||
|
target: entry.target,
|
||||||
|
status: normalizedStatus,
|
||||||
|
statusLabel: entry.statusLabel,
|
||||||
|
rawLine,
|
||||||
|
checkedAt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
47
src/main/services/extensions/runtime/mcpRuntimeJson.ts
Normal file
47
src/main/services/extensions/runtime/mcpRuntimeJson.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
interface McpListJsonServer {
|
||||||
|
name?: string;
|
||||||
|
scope?: string;
|
||||||
|
transport?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpListJsonPayload {
|
||||||
|
servers?: McpListJsonServer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonObject<T>(raw: string): T {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as T;
|
||||||
|
} catch {
|
||||||
|
const start = trimmed.indexOf('{');
|
||||||
|
const end = trimmed.lastIndexOf('}');
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
return JSON.parse(trimmed.slice(start, end + 1)) as T;
|
||||||
|
}
|
||||||
|
throw new Error('No JSON object found in CLI output');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedScope(scope: unknown): scope is InstalledMcpEntry['scope'] {
|
||||||
|
return scope === 'user' || scope === 'project' || scope === 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInstalledMcpJsonOutput(output: string): InstalledMcpEntry[] {
|
||||||
|
const parsed = extractJsonObject<McpListJsonPayload>(output);
|
||||||
|
|
||||||
|
return (parsed.servers ?? []).flatMap<InstalledMcpEntry>((entry) => {
|
||||||
|
if (typeof entry.name !== 'string' || !isSupportedScope(entry.scope)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: entry.name,
|
||||||
|
scope: entry.scope,
|
||||||
|
transport: typeof entry.transport === 'string' ? entry.transport : undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,97 +1,27 @@
|
||||||
/**
|
/**
|
||||||
* Runs `claude mcp list` and parses per-server health statuses.
|
* Resolves MCP diagnostics through the active runtime adapter.
|
||||||
|
*
|
||||||
|
* Direct Claude mode parses `claude mcp list` text output.
|
||||||
|
* Multimodel mode uses the structured `mcp diagnose --json` runtime contract.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import {
|
||||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
parseMcpDiagnosticsJsonOutput,
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
parseMcpDiagnosticsOutput,
|
||||||
import { createLogger } from '@shared/utils/logger';
|
} from '../runtime/mcpDiagnosticsParser';
|
||||||
|
|
||||||
import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions';
|
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
|
import type { McpServerDiagnostic } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:McpHealthDiagnostics');
|
export { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput };
|
||||||
|
|
||||||
const TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
export class McpHealthDiagnosticsService {
|
export class McpHealthDiagnosticsService {
|
||||||
async diagnose(): Promise<McpServerDiagnostic[]> {
|
constructor(
|
||||||
const claudeBinary = await ClaudeBinaryResolver.resolve();
|
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||||
if (!claudeBinary) {
|
) {}
|
||||||
throw new Error(CLI_NOT_FOUND_MESSAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stdout, stderr } = await execCli(claudeBinary, ['mcp', 'list'], {
|
async diagnose(projectPath?: string): Promise<McpServerDiagnostic[]> {
|
||||||
timeout: TIMEOUT_MS,
|
return this.runtimeAdapter.diagnoseMcp(projectPath);
|
||||||
env: buildEnrichedEnv(claudeBinary),
|
|
||||||
});
|
|
||||||
|
|
||||||
const output = [stdout, stderr].filter(Boolean).join('\n');
|
|
||||||
const diagnostics = parseMcpDiagnosticsOutput(output);
|
|
||||||
|
|
||||||
logger.info(`Parsed ${diagnostics.length} MCP diagnostic entries`);
|
|
||||||
return diagnostics;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] {
|
|
||||||
const checkedAt = Date.now();
|
|
||||||
|
|
||||||
return output
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health'))
|
|
||||||
.map((line) => parseDiagnosticLine(line, checkedAt))
|
|
||||||
.filter((entry): entry is McpServerDiagnostic => entry !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null {
|
|
||||||
const statusSeparatorIdx = line.lastIndexOf(' - ');
|
|
||||||
if (statusSeparatorIdx === -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const descriptor = line.slice(0, statusSeparatorIdx).trim();
|
|
||||||
const statusChunk = line.slice(statusSeparatorIdx + 3).trim();
|
|
||||||
|
|
||||||
const nameSeparatorIdx = descriptor.indexOf(': ');
|
|
||||||
if (nameSeparatorIdx === -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = descriptor.slice(0, nameSeparatorIdx).trim();
|
|
||||||
const target = descriptor.slice(nameSeparatorIdx + 2).trim();
|
|
||||||
if (!name || !target) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { status, statusLabel } = parseStatusChunk(statusChunk);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
target,
|
|
||||||
status,
|
|
||||||
statusLabel,
|
|
||||||
rawLine: line,
|
|
||||||
checkedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStatusChunk(statusChunk: string): {
|
|
||||||
status: McpServerHealthStatus;
|
|
||||||
statusLabel: string;
|
|
||||||
} {
|
|
||||||
const symbol = statusChunk[0];
|
|
||||||
const label = statusChunk.slice(1).trim() || 'Unknown';
|
|
||||||
|
|
||||||
switch (symbol) {
|
|
||||||
case '✓':
|
|
||||||
return { status: 'connected', statusLabel: label };
|
|
||||||
case '!':
|
|
||||||
return { status: 'needs-authentication', statusLabel: label };
|
|
||||||
case '✗':
|
|
||||||
return { status: 'failed', statusLabel: label };
|
|
||||||
default:
|
|
||||||
return { status: 'unknown', statusLabel: statusChunk };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Reads installed MCP server state from the filesystem.
|
* Resolves installed MCP server state through the active runtime adapter.
|
||||||
*
|
*
|
||||||
* Sources:
|
* Direct Claude mode reads CLI-managed config files.
|
||||||
* - User scope: ~/.claude.json → mcpServers
|
* Multimodel mode uses the structured `mcp list --json` runtime contract.
|
||||||
* - Local scope: ~/.claude.json → projects[projectPath].mcpServers
|
|
||||||
* - Project scope: .mcp.json in project root
|
|
||||||
*
|
|
||||||
* Both files are managed by the Claude CLI. This service is read-only.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'node:fs/promises';
|
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import * as path from 'node:path';
|
|
||||||
|
|
||||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
|
|
||||||
|
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:McpState');
|
|
||||||
|
|
||||||
const CACHE_TTL_MS = 10_000; // 10 seconds
|
const CACHE_TTL_MS = 10_000; // 10 seconds
|
||||||
|
|
||||||
interface TimedCache<T> {
|
interface TimedCache<T> {
|
||||||
|
|
@ -29,113 +20,23 @@ interface TimedCache<T> {
|
||||||
export class McpInstallationStateService {
|
export class McpInstallationStateService {
|
||||||
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
|
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
|
||||||
|
|
||||||
/**
|
constructor(
|
||||||
* Get all installed MCP servers across user, local, and project scopes.
|
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||||
*/
|
) {}
|
||||||
|
|
||||||
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||||
const cacheKey = projectPath ?? '__user__';
|
const cacheKey = `${this.runtimeAdapter.flavor}:${projectPath ?? '__user__'}`;
|
||||||
const cached = this.cache.get(cacheKey);
|
const cached = this.cache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries: InstalledMcpEntry[] = [];
|
const entries = await this.runtimeAdapter.getInstalledMcp(projectPath);
|
||||||
const claudeConfig = await this.readClaudeConfig();
|
|
||||||
|
|
||||||
// User scope: ~/.claude.json
|
|
||||||
entries.push(...this.readUserMcpServers(claudeConfig));
|
|
||||||
|
|
||||||
if (projectPath) {
|
|
||||||
entries.push(...this.readLocalMcpServers(claudeConfig, projectPath));
|
|
||||||
entries.push(...(await this.readProjectMcpServers(projectPath)));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
|
this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate cache. Call after install/uninstall operations.
|
|
||||||
*/
|
|
||||||
invalidateCache(): void {
|
invalidateCache(): void {
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async readClaudeConfig(): Promise<Record<string, unknown> | null> {
|
|
||||||
const configPath = path.join(getHomeDir(), '.claude.json');
|
|
||||||
try {
|
|
||||||
const raw = await fs.readFile(configPath, 'utf-8');
|
|
||||||
return JSON.parse(raw) as Record<string, unknown>;
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
logger.error(`Failed to read MCP servers from ${configPath}:`, err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private readUserMcpServers(config: Record<string, unknown> | null): InstalledMcpEntry[] {
|
|
||||||
return this.readMcpServersFromConfig(config?.mcpServers, 'user');
|
|
||||||
}
|
|
||||||
|
|
||||||
private readLocalMcpServers(
|
|
||||||
config: Record<string, unknown> | null,
|
|
||||||
projectPath: string
|
|
||||||
): InstalledMcpEntry[] {
|
|
||||||
const projects =
|
|
||||||
config && typeof config.projects === 'object' && config.projects
|
|
||||||
? (config.projects as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const projectConfig =
|
|
||||||
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
|
|
||||||
? (projects[projectPath] as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
|
|
||||||
const configPath = path.join(projectPath, '.mcp.json');
|
|
||||||
return this.readMcpServersFromFile(configPath, 'project');
|
|
||||||
}
|
|
||||||
|
|
||||||
private readMcpServersFromConfig(
|
|
||||||
value: unknown,
|
|
||||||
scope: 'user' | 'project' | 'local'
|
|
||||||
): InstalledMcpEntry[] {
|
|
||||||
const mcpServers =
|
|
||||||
value && typeof value === 'object'
|
|
||||||
? (value as Record<string, { command?: string; url?: string }>)
|
|
||||||
: null;
|
|
||||||
if (!mcpServers) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
|
|
||||||
let transport: string | undefined;
|
|
||||||
if (config.command) transport = 'stdio';
|
|
||||||
else if (config.url) transport = 'http';
|
|
||||||
|
|
||||||
return { name, scope, transport };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readMcpServersFromFile(
|
|
||||||
filePath: string,
|
|
||||||
scope: 'user' | 'project'
|
|
||||||
): Promise<InstalledMcpEntry[]> {
|
|
||||||
try {
|
|
||||||
const raw = await fs.readFile(filePath, 'utf-8');
|
|
||||||
const json = JSON.parse(raw) as Record<string, unknown>;
|
|
||||||
return this.readMcpServersFromConfig(json.mcpServers, scope);
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
logger.error(`Failed to read MCP servers from ${filePath}:`, err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
} from '@main/utils/shellEnv';
|
} from '@main/utils/shellEnv';
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { createWriteStream, existsSync, promises as fsp } from 'fs';
|
import { createWriteStream, existsSync, promises as fsp } from 'fs';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
|
@ -145,7 +146,13 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
||||||
...provider,
|
...provider,
|
||||||
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
||||||
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
||||||
capabilities: { ...provider.capabilities },
|
capabilities: {
|
||||||
|
...provider.capabilities,
|
||||||
|
extensions: {
|
||||||
|
...createDefaultCliExtensionCapabilities(),
|
||||||
|
...provider.capabilities.extensions,
|
||||||
|
},
|
||||||
|
},
|
||||||
selectedBackendId: provider.selectedBackendId ?? null,
|
selectedBackendId: provider.selectedBackendId ?? null,
|
||||||
resolvedBackendId: provider.resolvedBackendId ?? null,
|
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||||
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
|
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
|
||||||
|
|
@ -467,6 +474,7 @@ export class CliInstallerService {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
oneShot: false,
|
oneShot: false,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
backend: null,
|
backend: null,
|
||||||
}))
|
}))
|
||||||
|
|
@ -528,7 +536,13 @@ export class CliInstallerService {
|
||||||
authMethod: provider.authMethod,
|
authMethod: provider.authMethod,
|
||||||
selectedBackendId: provider.selectedBackendId ?? null,
|
selectedBackendId: provider.selectedBackendId ?? null,
|
||||||
resolvedBackendId: provider.resolvedBackendId ?? null,
|
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||||
capabilities: { ...provider.capabilities },
|
capabilities: {
|
||||||
|
...provider.capabilities,
|
||||||
|
extensions: {
|
||||||
|
...createDefaultCliExtensionCapabilities(),
|
||||||
|
...provider.capabilities.extensions,
|
||||||
|
},
|
||||||
|
},
|
||||||
backend: provider.backend ? { ...provider.backend } : null,
|
backend: provider.backend ? { ...provider.backend } : null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||||
|
|
@ -13,6 +14,19 @@ const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||||
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
|
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
|
||||||
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
|
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
interface RuntimeExtensionCapabilityResponse {
|
||||||
|
status?: 'supported' | 'read-only' | 'unsupported';
|
||||||
|
ownership?: 'shared' | 'provider-scoped';
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeExtensionCapabilitiesResponse {
|
||||||
|
plugins?: RuntimeExtensionCapabilityResponse;
|
||||||
|
mcp?: RuntimeExtensionCapabilityResponse;
|
||||||
|
skills?: RuntimeExtensionCapabilityResponse;
|
||||||
|
apiKeys?: RuntimeExtensionCapabilityResponse;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProviderStatusCommandResponse {
|
interface ProviderStatusCommandResponse {
|
||||||
schemaVersion?: number;
|
schemaVersion?: number;
|
||||||
providers?: Record<
|
providers?: Record<
|
||||||
|
|
@ -27,6 +41,7 @@ interface ProviderStatusCommandResponse {
|
||||||
capabilities?: {
|
capabilities?: {
|
||||||
teamLaunch?: boolean;
|
teamLaunch?: boolean;
|
||||||
oneShot?: boolean;
|
oneShot?: boolean;
|
||||||
|
extensions?: RuntimeExtensionCapabilitiesResponse;
|
||||||
};
|
};
|
||||||
backend?: {
|
backend?: {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|
@ -84,6 +99,7 @@ interface UnifiedRuntimeStatusResponse {
|
||||||
capabilities?: {
|
capabilities?: {
|
||||||
teamLaunch?: boolean;
|
teamLaunch?: boolean;
|
||||||
oneShot?: boolean;
|
oneShot?: boolean;
|
||||||
|
extensions?: RuntimeExtensionCapabilitiesResponse;
|
||||||
};
|
};
|
||||||
backend?: {
|
backend?: {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|
@ -129,6 +145,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
oneShot: false,
|
oneShot: false,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
selectedBackendId: null,
|
selectedBackendId: null,
|
||||||
resolvedBackendId: null,
|
resolvedBackendId: null,
|
||||||
|
|
@ -139,6 +156,39 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapRuntimeExtensionCapabilities(
|
||||||
|
capabilities?: RuntimeExtensionCapabilitiesResponse
|
||||||
|
): CliProviderStatus['capabilities']['extensions'] {
|
||||||
|
const defaults = createDefaultCliExtensionCapabilities();
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: {
|
||||||
|
...defaults.plugins,
|
||||||
|
status: capabilities?.plugins?.status ?? defaults.plugins.status,
|
||||||
|
ownership: capabilities?.plugins?.ownership ?? defaults.plugins.ownership,
|
||||||
|
reason: capabilities?.plugins?.reason ?? defaults.plugins.reason,
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
...defaults.mcp,
|
||||||
|
status: capabilities?.mcp?.status ?? defaults.mcp.status,
|
||||||
|
ownership: capabilities?.mcp?.ownership ?? defaults.mcp.ownership,
|
||||||
|
reason: capabilities?.mcp?.reason ?? defaults.mcp.reason,
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
...defaults.skills,
|
||||||
|
status: capabilities?.skills?.status ?? defaults.skills.status,
|
||||||
|
ownership: capabilities?.skills?.ownership ?? defaults.skills.ownership,
|
||||||
|
reason: capabilities?.skills?.reason ?? defaults.skills.reason,
|
||||||
|
},
|
||||||
|
apiKeys: {
|
||||||
|
...defaults.apiKeys,
|
||||||
|
status: capabilities?.apiKeys?.status ?? defaults.apiKeys.status,
|
||||||
|
ownership: capabilities?.apiKeys?.ownership ?? defaults.apiKeys.ownership,
|
||||||
|
reason: capabilities?.apiKeys?.reason ?? defaults.apiKeys.reason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function extractModelIds(
|
function extractModelIds(
|
||||||
models: (string | { id?: string; label?: string; description?: string })[] | undefined
|
models: (string | { id?: string; label?: string; description?: string })[] | undefined
|
||||||
): string[] {
|
): string[] {
|
||||||
|
|
@ -203,6 +253,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||||
|
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
|
||||||
},
|
},
|
||||||
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
|
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
|
||||||
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
|
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
|
||||||
|
|
@ -325,6 +376,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
provider.capabilities = {
|
provider.capabilities = {
|
||||||
teamLaunch: true,
|
teamLaunch: true,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -428,6 +480,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||||
|
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
|
||||||
},
|
},
|
||||||
backend: runtimeStatus.backend?.kind
|
backend: runtimeStatus.backend?.kind
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
|
|
@ -1578,7 +1578,8 @@ const electronAPI: ElectronAPI = {
|
||||||
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
|
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
|
||||||
getInstalled: (projectPath?: string) =>
|
getInstalled: (projectPath?: string) =>
|
||||||
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
|
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
|
||||||
diagnose: () => invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE),
|
diagnose: (projectPath?: string) =>
|
||||||
|
invokeIpcWithResult<McpServerDiagnostic[]>(MCP_REGISTRY_DIAGNOSE, projectPath),
|
||||||
install: (request: McpInstallRequest) =>
|
install: (request: McpInstallRequest) =>
|
||||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
||||||
installCustom: (request: McpCustomInstallRequest) =>
|
installCustom: (request: McpCustomInstallRequest) =>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||||
|
|
@ -36,6 +37,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
oneShot: false,
|
oneShot: false,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
backend: null,
|
backend: null,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,22 @@ export interface CliExternalRuntimeDiagnostic {
|
||||||
detailMessage?: string | null;
|
detailMessage?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CliExtensionCapabilityStatus = 'supported' | 'read-only' | 'unsupported';
|
||||||
|
export type CliExtensionOwnership = 'shared' | 'provider-scoped';
|
||||||
|
|
||||||
|
export interface CliExtensionCapability {
|
||||||
|
status: CliExtensionCapabilityStatus;
|
||||||
|
ownership: CliExtensionOwnership;
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliExtensionCapabilities {
|
||||||
|
plugins: CliExtensionCapability;
|
||||||
|
mcp: CliExtensionCapability;
|
||||||
|
skills: CliExtensionCapability;
|
||||||
|
apiKeys: CliExtensionCapability;
|
||||||
|
}
|
||||||
|
|
||||||
export type CliProviderModelAvailabilityStatus =
|
export type CliProviderModelAvailabilityStatus =
|
||||||
| 'checking'
|
| 'checking'
|
||||||
| 'available'
|
| 'available'
|
||||||
|
|
@ -85,6 +101,7 @@ export interface CliProviderStatus {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: boolean;
|
teamLaunch: boolean;
|
||||||
oneShot: boolean;
|
oneShot: boolean;
|
||||||
|
extensions: CliExtensionCapabilities;
|
||||||
};
|
};
|
||||||
selectedBackendId?: string | null;
|
selectedBackendId?: string | null;
|
||||||
resolvedBackendId?: string | null;
|
resolvedBackendId?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export interface McpCatalogAPI {
|
||||||
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
|
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
|
||||||
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
||||||
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
||||||
diagnose: () => Promise<McpServerDiagnostic[]>;
|
diagnose: (projectPath?: string) => Promise<McpServerDiagnostic[]>;
|
||||||
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
||||||
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
|
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
|
||||||
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
||||||
|
|
|
||||||
48
src/shared/utils/providerExtensionCapabilities.ts
Normal file
48
src/shared/utils/providerExtensionCapabilities.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type {
|
||||||
|
CliExtensionCapabilities,
|
||||||
|
CliExtensionCapability,
|
||||||
|
CliProviderStatus,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
const SUPPORTED_SHARED_CAPABILITY: CliExtensionCapability = {
|
||||||
|
status: 'supported',
|
||||||
|
ownership: 'shared',
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDefaultCliExtensionCapabilities(
|
||||||
|
overrides?: Partial<CliExtensionCapabilities>
|
||||||
|
): CliExtensionCapabilities {
|
||||||
|
return {
|
||||||
|
plugins: { ...SUPPORTED_SHARED_CAPABILITY },
|
||||||
|
mcp: { ...SUPPORTED_SHARED_CAPABILITY },
|
||||||
|
skills: { ...SUPPORTED_SHARED_CAPABILITY },
|
||||||
|
apiKeys: { ...SUPPORTED_SHARED_CAPABILITY },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCliProviderExtensionCapabilities(
|
||||||
|
provider: Pick<CliProviderStatus, 'capabilities'>
|
||||||
|
): CliExtensionCapabilities {
|
||||||
|
return provider.capabilities.extensions ?? createDefaultCliExtensionCapabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCliProviderExtensionCapability(
|
||||||
|
provider: Pick<CliProviderStatus, 'capabilities'>,
|
||||||
|
section: keyof CliExtensionCapabilities
|
||||||
|
): CliExtensionCapability {
|
||||||
|
return getCliProviderExtensionCapabilities(provider)[section];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCliExtensionCapabilityAvailable(
|
||||||
|
capability: Pick<CliExtensionCapability, 'status'>
|
||||||
|
): boolean {
|
||||||
|
return capability.status === 'supported' || capability.status === 'read-only';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCliExtensionCapabilityMutable(
|
||||||
|
capability: Pick<CliExtensionCapability, 'status'>
|
||||||
|
): boolean {
|
||||||
|
return capability.status === 'supported';
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { parseMcpDiagnosticsOutput } from '@main/services/extensions/state/McpHealthDiagnosticsService';
|
import {
|
||||||
|
McpHealthDiagnosticsService,
|
||||||
|
parseMcpDiagnosticsJsonOutput,
|
||||||
|
parseMcpDiagnosticsOutput,
|
||||||
|
} from '@main/services/extensions/state/McpHealthDiagnosticsService';
|
||||||
|
|
||||||
describe('parseMcpDiagnosticsOutput', () => {
|
describe('parseMcpDiagnosticsOutput', () => {
|
||||||
it('parses mixed MCP health lines from claude mcp list', () => {
|
it('parses mixed MCP health lines from claude mcp list', () => {
|
||||||
|
|
@ -40,4 +44,65 @@ another log line`);
|
||||||
|
|
||||||
expect(diagnostics).toEqual([]);
|
expect(diagnostics).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses structured multimodel MCP diagnostics JSON', () => {
|
||||||
|
const diagnostics = parseMcpDiagnosticsJsonOutput(
|
||||||
|
JSON.stringify({
|
||||||
|
checkedAt: '2026-04-17T10:00:00.000Z',
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
name: 'context7',
|
||||||
|
target: 'npx -y @upstash/context7-mcp',
|
||||||
|
status: 'connected',
|
||||||
|
statusLabel: 'Connected',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tavily',
|
||||||
|
target: 'https://mcp.tavily.com/mcp',
|
||||||
|
status: 'timeout',
|
||||||
|
statusLabel: 'Timed out',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(diagnostics).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'context7',
|
||||||
|
status: 'connected',
|
||||||
|
statusLabel: 'Connected',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'tavily',
|
||||||
|
status: 'failed',
|
||||||
|
statusLabel: 'Timed out',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('McpHealthDiagnosticsService', () => {
|
||||||
|
it('delegates diagnostics to the active runtime adapter', async () => {
|
||||||
|
const diagnoseMcp = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: 'context7',
|
||||||
|
target: 'npx -y @upstash/context7-mcp',
|
||||||
|
status: 'connected',
|
||||||
|
statusLabel: 'Connected',
|
||||||
|
rawLine: 'context7: npx -y @upstash/context7-mcp - Connected',
|
||||||
|
checkedAt: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const service = new McpHealthDiagnosticsService({
|
||||||
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
buildManagementCliEnv: vi.fn(),
|
||||||
|
getInstalledMcp: vi.fn(),
|
||||||
|
diagnoseMcp,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.diagnose('/tmp/project-a')).resolves.toEqual([
|
||||||
|
expect.objectContaining({ name: 'context7' }),
|
||||||
|
]);
|
||||||
|
expect(diagnoseMcp).toHaveBeenCalledWith('/tmp/project-a');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter';
|
||||||
|
import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader';
|
||||||
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
|
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
|
||||||
|
|
||||||
vi.mock('@main/utils/pathDecoder', () => ({
|
vi.mock('@main/utils/pathDecoder', () => ({
|
||||||
getHomeDir: () => '/tmp/mock-home',
|
getHomeDir: () => '/tmp/mock-home',
|
||||||
|
getClaudeBasePath: () => '/tmp/mock-home/.claude',
|
||||||
|
setClaudeBasePathOverride: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('node:fs/promises');
|
vi.mock('node:fs/promises');
|
||||||
|
|
@ -14,7 +18,9 @@ describe('McpInstallationStateService', () => {
|
||||||
const mockedFs = vi.mocked(fs);
|
const mockedFs = vi.mocked(fs);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new McpInstallationStateService();
|
service = new McpInstallationStateService(
|
||||||
|
new ClaudeExtensionsAdapter(new McpConfigStateReader())
|
||||||
|
);
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -146,5 +152,28 @@ describe('McpInstallationStateService', () => {
|
||||||
]);
|
]);
|
||||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
|
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports multimodel MCP state through the runtime adapter contract', async () => {
|
||||||
|
const getInstalledMcp = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([{ name: 'context7', scope: 'user', transport: 'stdio' }])
|
||||||
|
.mockResolvedValueOnce([{ name: 'repo-mcp', scope: 'project', transport: 'http' }]);
|
||||||
|
service = new McpInstallationStateService({
|
||||||
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
buildManagementCliEnv: vi.fn(),
|
||||||
|
diagnoseMcp: vi.fn(),
|
||||||
|
getInstalledMcp,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.getInstalled('/tmp/project-a')).resolves.toEqual([
|
||||||
|
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
||||||
|
]);
|
||||||
|
await expect(service.getInstalled('/tmp/project-b')).resolves.toEqual([
|
||||||
|
{ name: 'repo-mcp', scope: 'project', transport: 'http' },
|
||||||
|
]);
|
||||||
|
expect(getInstalledMcp).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getInstalledMcp).toHaveBeenNthCalledWith(1, '/tmp/project-a');
|
||||||
|
expect(getInstalledMcp).toHaveBeenNthCalledWith(2, '/tmp/project-b');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,16 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
authMethod: 'oauth_token',
|
authMethod: 'oauth_token',
|
||||||
verificationState: 'verified',
|
verificationState: 'verified',
|
||||||
canLoginFromUi: true,
|
canLoginFromUi: true,
|
||||||
capabilities: { teamLaunch: true, oneShot: true },
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
extensions: {
|
||||||
|
plugins: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
mcp: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
skills: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
backend: { kind: 'anthropic', label: 'Anthropic' },
|
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
|
|
@ -102,7 +111,20 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
verificationState: 'verified',
|
verificationState: 'verified',
|
||||||
canLoginFromUi: true,
|
canLoginFromUi: true,
|
||||||
statusMessage: 'Not connected',
|
statusMessage: 'Not connected',
|
||||||
capabilities: { teamLaunch: true, oneShot: true },
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
extensions: {
|
||||||
|
plugins: {
|
||||||
|
status: 'unsupported',
|
||||||
|
ownership: 'shared',
|
||||||
|
reason: 'Anthropic only',
|
||||||
|
},
|
||||||
|
mcp: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
skills: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
backend: { kind: 'openai', label: 'OpenAI' },
|
backend: { kind: 'openai', label: 'OpenAI' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -166,6 +188,15 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
models: ['gpt-5-codex'],
|
models: ['gpt-5-codex'],
|
||||||
statusMessage: 'Not connected',
|
statusMessage: 'Not connected',
|
||||||
|
capabilities: {
|
||||||
|
extensions: {
|
||||||
|
plugins: {
|
||||||
|
status: 'unsupported',
|
||||||
|
ownership: 'shared',
|
||||||
|
reason: 'Anthropic only',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(providers[2]).toMatchObject({
|
expect(providers[2]).toMatchObject({
|
||||||
providerId: 'gemini',
|
providerId: 'gemini',
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
CliProviderModelAvailabilityService,
|
CliProviderModelAvailabilityService,
|
||||||
type ProviderModelAvailabilityContext,
|
type ProviderModelAvailabilityContext,
|
||||||
} from '@main/services/runtime/CliProviderModelAvailabilityService';
|
} from '@main/services/runtime/CliProviderModelAvailabilityService';
|
||||||
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
function createContext(models: string[]): ProviderModelAvailabilityContext {
|
function createContext(models: string[]): ProviderModelAvailabilityContext {
|
||||||
return {
|
return {
|
||||||
|
|
@ -33,6 +34,7 @@ function createContext(models: string[]): ProviderModelAvailabilityContext {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: true,
|
teamLaunch: true,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
backend: {
|
backend: {
|
||||||
kind: 'openai',
|
kind: 'openai',
|
||||||
|
|
@ -78,12 +80,12 @@ describe('CliProviderModelAvailabilityService', () => {
|
||||||
connectionIssues: {},
|
connectionIssues: {},
|
||||||
});
|
});
|
||||||
execCliMock.mockRejectedValue(
|
execCliMock.mockRejectedValue(
|
||||||
new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.")
|
new Error("The 'gpt-5.4' model is not supported when using Codex with a ChatGPT account.")
|
||||||
);
|
);
|
||||||
|
|
||||||
const onUpdate = vi.fn();
|
const onUpdate = vi.fn();
|
||||||
const service = new CliProviderModelAvailabilityService(onUpdate);
|
const service = new CliProviderModelAvailabilityService(onUpdate);
|
||||||
service.getSnapshot(createContext(['gpt-5.2-codex']));
|
service.getSnapshot(createContext(['gpt-5.4']));
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(onUpdate).toHaveBeenCalledWith(
|
expect(onUpdate).toHaveBeenCalledWith(
|
||||||
|
|
@ -92,7 +94,7 @@ describe('CliProviderModelAvailabilityService', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
modelAvailability: [
|
modelAvailability: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
modelId: 'gpt-5.2-codex',
|
modelId: 'gpt-5.4',
|
||||||
status: 'unavailable',
|
status: 'unavailable',
|
||||||
reason: 'Not available with Codex ChatGPT subscription',
|
reason: 'Not available with Codex ChatGPT subscription',
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||||
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
function createCodexProvider(
|
function createCodexProvider(
|
||||||
overrides?: Partial<CliProviderStatus['connection']> & {
|
overrides?: Partial<CliProviderStatus['connection']> & {
|
||||||
|
|
@ -169,6 +170,7 @@ function createCodexProvider(
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: true,
|
teamLaunch: true,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
selectedBackendId: 'auto',
|
selectedBackendId: 'auto',
|
||||||
resolvedBackendId: 'adapter',
|
resolvedBackendId: 'adapter',
|
||||||
|
|
@ -210,6 +212,7 @@ function createAnthropicProvider(
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: true,
|
teamLaunch: true,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
selectedBackendId: null,
|
selectedBackendId: null,
|
||||||
resolvedBackendId: null,
|
resolvedBackendId: null,
|
||||||
|
|
@ -241,6 +244,7 @@ function createGeminiProvider(): CliProviderStatus {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: true,
|
teamLaunch: true,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
selectedBackendId: 'auto',
|
selectedBackendId: 'auto',
|
||||||
resolvedBackendId: 'api',
|
resolvedBackendId: 'api',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
getProviderCredentialSummary,
|
getProviderCredentialSummary,
|
||||||
getProviderCurrentRuntimeSummary,
|
getProviderCurrentRuntimeSummary,
|
||||||
} from '@renderer/components/runtime/providerConnectionUi';
|
} from '@renderer/components/runtime/providerConnectionUi';
|
||||||
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
import type { CliProviderStatus } from '@shared/types';
|
import type { CliProviderStatus } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ function createAnthropicProvider(
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: true,
|
teamLaunch: true,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
selectedBackendId: null,
|
selectedBackendId: null,
|
||||||
resolvedBackendId: null,
|
resolvedBackendId: null,
|
||||||
|
|
@ -64,6 +66,7 @@ function createCodexProvider(
|
||||||
capabilities: {
|
capabilities: {
|
||||||
teamLaunch: true,
|
teamLaunch: true,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
},
|
},
|
||||||
selectedBackendId: 'auto',
|
selectedBackendId: 'auto',
|
||||||
resolvedBackendId: 'adapter',
|
resolvedBackendId: 'adapter',
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ vi.mock('@renderer/api', () => ({
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
import type { CliInstallationStatus } from '@shared/types';
|
import type { CliInstallationStatus } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -196,7 +197,11 @@ describe('cliInstallerSlice', () => {
|
||||||
statusMessage: 'Runtime found, but startup health check failed.',
|
statusMessage: 'Runtime found, but startup health check failed.',
|
||||||
models: [],
|
models: [],
|
||||||
canLoginFromUi: false,
|
canLoginFromUi: false,
|
||||||
capabilities: { teamLaunch: false, oneShot: false },
|
capabilities: {
|
||||||
|
teamLaunch: false,
|
||||||
|
oneShot: false,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
|
},
|
||||||
backend: null,
|
backend: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue