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,
|
||||
SkillsMutationService,
|
||||
SkillsWatcherService,
|
||||
createExtensionsRuntimeAdapter,
|
||||
} from './services/extensions';
|
||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
|
|
@ -871,8 +872,9 @@ async function initializeServices(): Promise<void> {
|
|||
const officialMcpRegistry = new OfficialMcpRegistryService();
|
||||
const glamaMcpService = new GlamaMcpEnrichmentService();
|
||||
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
||||
const mcpStateService = new McpInstallationStateService();
|
||||
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService();
|
||||
const extensionsRuntimeAdapter = createExtensionsRuntimeAdapter();
|
||||
const mcpStateService = new McpInstallationStateService(extensionsRuntimeAdapter);
|
||||
const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(extensionsRuntimeAdapter);
|
||||
const skillsCatalogService = new SkillsCatalogService();
|
||||
const skillsMutationService = new SkillsMutationService();
|
||||
skillsWatcherService = new SkillsWatcherService();
|
||||
|
|
@ -884,8 +886,11 @@ async function initializeServices(): Promise<void> {
|
|||
);
|
||||
|
||||
// Install services — resolve binary dynamically via ClaudeBinaryResolver
|
||||
const pluginInstallService = new PluginInstallService(pluginCatalogService);
|
||||
const mcpInstallService = new McpInstallService(mcpAggregator);
|
||||
const pluginInstallService = new PluginInstallService(
|
||||
pluginCatalogService,
|
||||
extensionsRuntimeAdapter
|
||||
);
|
||||
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
|
||||
const apiKeyService = new ApiKeyService();
|
||||
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||
// warmup() and ensureInstalled() are deferred to after window creation
|
||||
|
|
|
|||
|
|
@ -239,8 +239,13 @@ function getMcpHealthDiagnostics(): McpHealthDiagnosticsService {
|
|||
return mcpHealthDiagnostics;
|
||||
}
|
||||
|
||||
async function handleMcpDiagnose(): Promise<IpcResult<McpServerDiagnostic[]>> {
|
||||
return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose());
|
||||
async function handleMcpDiagnose(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath?: string
|
||||
): Promise<IpcResult<McpServerDiagnostic[]>> {
|
||||
return wrapHandler('mcpDiagnose', () =>
|
||||
getMcpHealthDiagnostics().diagnose(typeof projectPath === 'string' ? projectPath : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Install/Uninstall Handlers ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export { SkillsCatalogService } from './skills/SkillsCatalogService';
|
|||
export { SkillsMutationService } from './skills/SkillsMutationService';
|
||||
export { SkillsWatcherService } from './skills/SkillsWatcherService';
|
||||
export { SkillValidator } from './skills/SkillValidator';
|
||||
export {
|
||||
ClaudeExtensionsAdapter,
|
||||
createExtensionsRuntimeAdapter,
|
||||
MultimodelExtensionsAdapter,
|
||||
} from './runtime/ExtensionsRuntimeAdapter';
|
||||
export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService';
|
||||
export { McpInstallationStateService } from './state/McpInstallationStateService';
|
||||
export { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@
|
|||
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import path from 'path';
|
||||
|
||||
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||
|
||||
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
||||
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||
import type {
|
||||
McpCustomInstallRequest,
|
||||
McpInstallRequest,
|
||||
|
|
@ -42,7 +44,10 @@ function scopeRequiresProjectPath(scope?: string): boolean {
|
|||
}
|
||||
|
||||
export class McpInstallService {
|
||||
constructor(private readonly aggregator: McpCatalogAggregator) {}
|
||||
constructor(
|
||||
private readonly aggregator: McpCatalogAggregator,
|
||||
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||
) {}
|
||||
|
||||
async install(request: McpInstallRequest): Promise<OperationResult> {
|
||||
const { registryId, serverName, scope, projectPath, envValues, headers } = request;
|
||||
|
|
@ -180,11 +185,12 @@ export class McpInstallService {
|
|||
error: CLI_NOT_FOUND_MESSAGE,
|
||||
};
|
||||
}
|
||||
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||
|
||||
const { stderr } = await execCli(claudeBinary, args, {
|
||||
timeout: TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
env: buildEnrichedEnv(claudeBinary),
|
||||
env,
|
||||
});
|
||||
|
||||
if (stderr) {
|
||||
|
|
@ -295,11 +301,12 @@ export class McpInstallService {
|
|||
error: CLI_NOT_FOUND_MESSAGE,
|
||||
};
|
||||
}
|
||||
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||
|
||||
const { stderr } = await execCli(claudeBinary, args, {
|
||||
timeout: TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
env: buildEnrichedEnv(claudeBinary),
|
||||
env,
|
||||
});
|
||||
|
||||
if (stderr) {
|
||||
|
|
@ -364,11 +371,12 @@ export class McpInstallService {
|
|||
error: CLI_NOT_FOUND_MESSAGE,
|
||||
};
|
||||
}
|
||||
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||
|
||||
await execCli(claudeBinary, args, {
|
||||
timeout: TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
env: buildEnrichedEnv(claudeBinary),
|
||||
env,
|
||||
});
|
||||
return { state: 'success' };
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@
|
|||
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import path from 'path';
|
||||
|
||||
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||
|
||||
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
|
||||
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:PluginInstall');
|
||||
|
|
@ -31,7 +33,10 @@ function scopeRequiresProjectPath(scope?: string): boolean {
|
|||
}
|
||||
|
||||
export class PluginInstallService {
|
||||
constructor(private readonly catalogService: PluginCatalogService) {}
|
||||
constructor(
|
||||
private readonly catalogService: PluginCatalogService,
|
||||
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||
) {}
|
||||
|
||||
async install(request: PluginInstallRequest): Promise<OperationResult> {
|
||||
const { pluginId, scope, projectPath } = request;
|
||||
|
|
@ -95,11 +100,12 @@ export class PluginInstallService {
|
|||
error: CLI_NOT_FOUND_MESSAGE,
|
||||
};
|
||||
}
|
||||
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||
|
||||
const { stdout, stderr } = await execCli(claudeBinary, args, {
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
env: buildEnrichedEnv(claudeBinary),
|
||||
env,
|
||||
});
|
||||
|
||||
if (stderr && !stdout) {
|
||||
|
|
@ -175,11 +181,12 @@ export class PluginInstallService {
|
|||
error: CLI_NOT_FOUND_MESSAGE,
|
||||
};
|
||||
}
|
||||
const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary);
|
||||
|
||||
await execCli(claudeBinary, args, {
|
||||
timeout: UNINSTALL_TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
env: buildEnrichedEnv(claudeBinary),
|
||||
env,
|
||||
});
|
||||
return { state: 'success' };
|
||||
} 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 { execCli } from '@main/utils/childProcess';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||
import {
|
||||
parseMcpDiagnosticsJsonOutput,
|
||||
parseMcpDiagnosticsOutput,
|
||||
} 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');
|
||||
|
||||
const TIMEOUT_MS = 30_000;
|
||||
export { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput };
|
||||
|
||||
export class McpHealthDiagnosticsService {
|
||||
async diagnose(): Promise<McpServerDiagnostic[]> {
|
||||
const claudeBinary = await ClaudeBinaryResolver.resolve();
|
||||
if (!claudeBinary) {
|
||||
throw new Error(CLI_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
constructor(
|
||||
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||
) {}
|
||||
|
||||
const { stdout, stderr } = await execCli(claudeBinary, ['mcp', 'list'], {
|
||||
timeout: TIMEOUT_MS,
|
||||
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 };
|
||||
async diagnose(projectPath?: string): Promise<McpServerDiagnostic[]> {
|
||||
return this.runtimeAdapter.diagnoseMcp(projectPath);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,15 @@
|
|||
/**
|
||||
* Reads installed MCP server state from the filesystem.
|
||||
* Resolves installed MCP server state through the active runtime adapter.
|
||||
*
|
||||
* Sources:
|
||||
* - User scope: ~/.claude.json → mcpServers
|
||||
* - 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.
|
||||
* Direct Claude mode reads CLI-managed config files.
|
||||
* Multimodel mode uses the structured `mcp list --json` runtime contract.
|
||||
*/
|
||||
|
||||
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 { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||
|
||||
import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:McpState');
|
||||
|
||||
const CACHE_TTL_MS = 10_000; // 10 seconds
|
||||
|
||||
interface TimedCache<T> {
|
||||
|
|
@ -29,113 +20,23 @@ interface TimedCache<T> {
|
|||
export class McpInstallationStateService {
|
||||
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
|
||||
|
||||
/**
|
||||
* Get all installed MCP servers across user, local, and project scopes.
|
||||
*/
|
||||
constructor(
|
||||
private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter()
|
||||
) {}
|
||||
|
||||
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||
const cacheKey = projectPath ?? '__user__';
|
||||
const cacheKey = `${this.runtimeAdapter.flavor}:${projectPath ?? '__user__'}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const entries: InstalledMcpEntry[] = [];
|
||||
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)));
|
||||
}
|
||||
|
||||
const entries = await this.runtimeAdapter.getInstalledMcp(projectPath);
|
||||
this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache. Call after install/uninstall operations.
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
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';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
import { createHash } from 'crypto';
|
||||
import { createWriteStream, existsSync, promises as fsp } from 'fs';
|
||||
import http from 'http';
|
||||
|
|
@ -145,7 +146,13 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
|||
...provider,
|
||||
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
||||
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
||||
capabilities: { ...provider.capabilities },
|
||||
capabilities: {
|
||||
...provider.capabilities,
|
||||
extensions: {
|
||||
...createDefaultCliExtensionCapabilities(),
|
||||
...provider.capabilities.extensions,
|
||||
},
|
||||
},
|
||||
selectedBackendId: provider.selectedBackendId ?? null,
|
||||
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
|
||||
|
|
@ -467,6 +474,7 @@ export class CliInstallerService {
|
|||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
backend: null,
|
||||
}))
|
||||
|
|
@ -528,7 +536,13 @@ export class CliInstallerService {
|
|||
authMethod: provider.authMethod,
|
||||
selectedBackendId: provider.selectedBackendId ?? null,
|
||||
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||
capabilities: { ...provider.capabilities },
|
||||
capabilities: {
|
||||
...provider.capabilities,
|
||||
extensions: {
|
||||
...createDefaultCliExtensionCapabilities(),
|
||||
...provider.capabilities.extensions,
|
||||
},
|
||||
},
|
||||
backend: provider.backend ? { ...provider.backend } : null,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||
|
|
@ -13,6 +14,19 @@ const logger = createLogger('ClaudeMultimodelBridgeService');
|
|||
const PROVIDER_STATUS_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 {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
|
|
@ -27,6 +41,7 @@ interface ProviderStatusCommandResponse {
|
|||
capabilities?: {
|
||||
teamLaunch?: boolean;
|
||||
oneShot?: boolean;
|
||||
extensions?: RuntimeExtensionCapabilitiesResponse;
|
||||
};
|
||||
backend?: {
|
||||
kind?: string;
|
||||
|
|
@ -84,6 +99,7 @@ interface UnifiedRuntimeStatusResponse {
|
|||
capabilities?: {
|
||||
teamLaunch?: boolean;
|
||||
oneShot?: boolean;
|
||||
extensions?: RuntimeExtensionCapabilitiesResponse;
|
||||
};
|
||||
backend?: {
|
||||
kind?: string;
|
||||
|
|
@ -129,6 +145,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
|||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
selectedBackendId: 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(
|
||||
models: (string | { id?: string; label?: string; description?: string })[] | undefined
|
||||
): string[] {
|
||||
|
|
@ -203,6 +253,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
capabilities: {
|
||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
|
||||
},
|
||||
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
|
||||
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
|
||||
|
|
@ -325,6 +376,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
provider.capabilities = {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -428,6 +480,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
capabilities: {
|
||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
|
||||
},
|
||||
backend: runtimeStatus.backend?.kind
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -1578,7 +1578,8 @@ const electronAPI: ElectronAPI = {
|
|||
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
|
||||
getInstalled: (projectPath?: string) =>
|
||||
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) =>
|
||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
||||
installCustom: (request: McpCustomInstallRequest) =>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
|
@ -36,6 +37,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
|||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
backend: null,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -57,6 +57,22 @@ export interface CliExternalRuntimeDiagnostic {
|
|||
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 =
|
||||
| 'checking'
|
||||
| 'available'
|
||||
|
|
@ -85,6 +101,7 @@ export interface CliProviderStatus {
|
|||
capabilities: {
|
||||
teamLaunch: boolean;
|
||||
oneShot: boolean;
|
||||
extensions: CliExtensionCapabilities;
|
||||
};
|
||||
selectedBackendId?: string | null;
|
||||
resolvedBackendId?: string | null;
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export interface McpCatalogAPI {
|
|||
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
|
||||
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
||||
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
||||
diagnose: () => Promise<McpServerDiagnostic[]>;
|
||||
diagnose: (projectPath?: string) => Promise<McpServerDiagnostic[]>;
|
||||
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
||||
installCustom: (request: McpCustomInstallRequest) => 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', () => {
|
||||
it('parses mixed MCP health lines from claude mcp list', () => {
|
||||
|
|
@ -40,4 +44,65 @@ another log line`);
|
|||
|
||||
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 * 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';
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getHomeDir: () => '/tmp/mock-home',
|
||||
getClaudeBasePath: () => '/tmp/mock-home/.claude',
|
||||
setClaudeBasePathOverride: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
|
@ -14,7 +18,9 @@ describe('McpInstallationStateService', () => {
|
|||
const mockedFs = vi.mocked(fs);
|
||||
|
||||
beforeEach(() => {
|
||||
service = new McpInstallationStateService();
|
||||
service = new McpInstallationStateService(
|
||||
new ClaudeExtensionsAdapter(new McpConfigStateReader())
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -146,5 +152,28 @@ describe('McpInstallationStateService', () => {
|
|||
]);
|
||||
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',
|
||||
verificationState: 'verified',
|
||||
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' },
|
||||
},
|
||||
codex: {
|
||||
|
|
@ -102,7 +111,20 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
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' },
|
||||
},
|
||||
},
|
||||
|
|
@ -166,6 +188,15 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
authenticated: false,
|
||||
models: ['gpt-5-codex'],
|
||||
statusMessage: 'Not connected',
|
||||
capabilities: {
|
||||
extensions: {
|
||||
plugins: {
|
||||
status: 'unsupported',
|
||||
ownership: 'shared',
|
||||
reason: 'Anthropic only',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(providers[2]).toMatchObject({
|
||||
providerId: 'gemini',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
CliProviderModelAvailabilityService,
|
||||
type ProviderModelAvailabilityContext,
|
||||
} from '@main/services/runtime/CliProviderModelAvailabilityService';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
function createContext(models: string[]): ProviderModelAvailabilityContext {
|
||||
return {
|
||||
|
|
@ -33,6 +34,7 @@ function createContext(models: string[]): ProviderModelAvailabilityContext {
|
|||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
backend: {
|
||||
kind: 'openai',
|
||||
|
|
@ -78,12 +80,12 @@ describe('CliProviderModelAvailabilityService', () => {
|
|||
connectionIssues: {},
|
||||
});
|
||||
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 service = new CliProviderModelAvailabilityService(onUpdate);
|
||||
service.getSnapshot(createContext(['gpt-5.2-codex']));
|
||||
service.getSnapshot(createContext(['gpt-5.4']));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith(
|
||||
|
|
@ -92,7 +94,7 @@ describe('CliProviderModelAvailabilityService', () => {
|
|||
expect.objectContaining({
|
||||
modelAvailability: [
|
||||
expect.objectContaining({
|
||||
modelId: 'gpt-5.2-codex',
|
||||
modelId: 'gpt-5.4',
|
||||
status: 'unavailable',
|
||||
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 { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
function createCodexProvider(
|
||||
overrides?: Partial<CliProviderStatus['connection']> & {
|
||||
|
|
@ -169,6 +170,7 @@ function createCodexProvider(
|
|||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
selectedBackendId: 'auto',
|
||||
resolvedBackendId: 'adapter',
|
||||
|
|
@ -210,6 +212,7 @@ function createAnthropicProvider(
|
|||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
|
|
@ -241,6 +244,7 @@ function createGeminiProvider(): CliProviderStatus {
|
|||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
selectedBackendId: 'auto',
|
||||
resolvedBackendId: 'api',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
getProviderCredentialSummary,
|
||||
getProviderCurrentRuntimeSummary,
|
||||
} from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
import type { CliProviderStatus } from '@shared/types';
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ function createAnthropicProvider(
|
|||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
|
|
@ -64,6 +66,7 @@ function createCodexProvider(
|
|||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
selectedBackendId: 'auto',
|
||||
resolvedBackendId: 'adapter',
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ vi.mock('@renderer/api', () => ({
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
|
||||
|
|
@ -196,7 +197,11 @@ describe('cliInstallerSlice', () => {
|
|||
statusMessage: 'Runtime found, but startup health check failed.',
|
||||
models: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: { teamLaunch: false, oneShot: false },
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
backend: null,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue