feat: add Extension Store with plugin catalog and MCP registry

Full Extension Store implementation (Phases 0-6):
- Plugin marketplace catalog with ETag caching and search/filter/sort
- MCP server registry with Official + Glama aggregation
- Install/uninstall flows for both plugins and MCP servers via CLI
- Per-tab UI state, skeleton loading, dashed empty states, card polish
- Input validation and security hardening (scope allowlists, env/header
  key regex, projectPath validation, HTTP body size limits)
- 8 test suites covering catalog, install, aggregation, normalizers
This commit is contained in:
iliya 2026-03-08 01:00:18 +02:00
parent 5c2b6fe68c
commit 126f8e2865
61 changed files with 8122 additions and 11 deletions

View file

@ -0,0 +1,307 @@
# ADR-001: Extension Store Contract Spike
**Date**: 2026-03-07
**Status**: Accepted
## Context
Extension Store нуждается в точных внешних контрактах перед написанием парсеров. Этот ADR фиксирует результаты contract spike.
---
## 1. Plugin CLI Contracts
### Verified CLI Flags
| Command | Syntax | Default scope |
|---------|--------|---------------|
| install | `claude plugin install [-s scope] <plugin>` | `user` |
| uninstall | `claude plugin uninstall [-s scope] <plugin>` | `user` |
| list | `claude plugin list [--json] [--available]` | — |
| enable | `claude plugin enable [-s scope] <plugin>` | — |
| disable | `claude plugin disable [plugin]` | — |
**Scope flag**: `-s, --scope <scope>` — values: `user`, `project`, `local`
**qualifiedName format**: `<name>@<marketplace>` (e.g. `context7@claude-plugins-official`)
### Installed State — Source of Truth
**File**: `~/.claude/plugins/installed_plugins.json`
```json
{
"version": 2,
"plugins": {
"<qualifiedName>": [
{
"scope": "user",
"installPath": "/Users/.../.claude/plugins/cache/<marketplace>/<name>/<version>",
"version": "1.0.0",
"installedAt": "2026-03-01T11:14:21.926Z",
"lastUpdated": "2026-03-01T11:14:21.926Z",
"gitCommitSha": "..."
}
]
}
}
```
- Key = `qualifiedName` = `<pluginName>@<marketplaceName>`
- Value = array (one entry per scope installation)
- `scope`: `"user"` | `"project"` | `"local"`
- **pluginId** for V1 = `qualifiedName` (globally unique)
### Install Counts
**File**: `~/.claude/plugins/install-counts-cache.json`
```json
{
"<pluginName>": <count> // NOT qualifiedName, just name
}
```
- Key = plugin `name` (without marketplace suffix)
- 157 entries in current cache
### Marketplaces
**File**: `~/.claude/plugins/known_marketplaces.json`
```json
{
"<marketplace-name>": {
"source": { "source": "github", "repo": "<owner>/<repo>" },
"installLocation": "...",
"lastUpdated": "..."
}
}
```
- V1: we read only `claude-plugins-official` marketplace
- Marketplace manifest: `raw.githubusercontent.com/<owner>/<repo>/main/.claude-plugin/marketplace.json`
- Supports ETag/If-None-Match → 304 Not Modified
### `claude plugin list --json`
- Supports `--json` flag
- Supports `--available` flag (requires `--json`)
- Output format: TBD (не тестировали мутирующе, но флаг существует)
---
## 2. MCP CLI Contracts
### Verified CLI Flags
| Command | Syntax |
|---------|--------|
| add (stdio) | `claude mcp add [-s scope] [-e KEY=val...] <name> -- <command> [args...]` |
| add (http) | `claude mcp add [-s scope] -t http [-H "Header: val"...] <name> <url>` |
| add (sse) | `claude mcp add [-s scope] -t sse [-H "Header: val"...] <name> <url>` |
| remove | `claude mcp remove [-s scope] <name>` |
| list | `claude mcp list` |
| get | `claude mcp get <name>` |
**Scope flag**: `-s, --scope <scope>` — values: `local` (default), `user`, `project`
**Transport flag**: `-t, --transport <transport>` — values: `stdio` (default), `sse`, `http`
**Env flag**: `-e, --env <env...>` — format: `KEY=value`
**Header flag**: `-H, --header <header...>` — format: `"Key: value"` — YES, SUPPORTED!
**OAuth**: `--client-id`, `--client-secret`, `--callback-port` — not needed for V1
### Installed State — Source of Truth
**User scope**: `~/.claude.json``mcpServers` key
**Project scope**: `.mcp.json` in project root
**Local scope**: определяется в Phase 0 (likely `~/.claude.json`)
---
## 3. Official MCP Registry API
**Base URL**: `https://registry.modelcontextprotocol.io/v0.1/servers`
**Pagination**: cursor-based via `metadata.nextCursor`
**Query params**:
- `limit=N` — items per page
- `search=<query>` — text search
- `cursor=<nextCursor>` — pagination
**Response structure**:
```json
{
"servers": [
{
"server": {
"name": "io.github.upstash/context7", // reverse-DNS ID
"description": "...",
"title": "Context7", // display name (optional)
"version": "1.0.30",
"repository": { "url": "...", "source": "github" },
"packages": [{ // npm install info (optional)
"registryType": "npm",
"identifier": "@upstash/context7-mcp",
"version": "1.0.30",
"transport": { "type": "stdio" },
"environmentVariables": [{
"name": "CONTEXT7_API_KEY",
"description": "...",
"isSecret": true,
"format": "string"
}]
}],
"remotes": [{ // HTTP/SSE install info (optional)
"type": "streamable-http",
"url": "https://...",
"headers": [{ // auth headers (optional)
"name": "Authorization",
"description": "...",
"isRequired": true,
"isSecret": true,
"value": "Bearer {key}" // template (optional)
}]
}]
},
"_meta": {
"io.modelcontextprotocol.registry/official": {
"status": "active",
"isLatest": true
}
}
}
],
"metadata": {
"nextCursor": "...",
"count": N
}
}
```
**Key fields for install**:
- `packages[0].identifier` → npm package name
- `packages[0].version` → npm version
- `packages[0].transport.type``"stdio"`
- `packages[0].environmentVariables` → env var definitions
- `remotes[0].type``"streamable-http"` | `"sse"`
- `remotes[0].url` → HTTP endpoint
- `remotes[0].headers` → auth headers (with `isSecret`, `isRequired`)
**Version handling**: Multiple versions of same server returned separately. Use `_meta.isLatest: true` to pick latest.
**Auth**: No authentication required.
---
## 4. Glama API
**Base URL**: `https://glama.ai/api/mcp/v1/servers`
**Pagination**: cursor-based via `pageInfo.endCursor`
**Query params**:
- `first=N` — items per page
- `search=<query>` — text search
- `after=<cursor>` — pagination cursor
**Response structure**:
```json
{
"pageInfo": {
"endCursor": "...",
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "..."
},
"servers": [{
"id": "iu27vfrji2",
"name": "clelp-mcp-server",
"namespace": "oscarsterling",
"description": "...",
"slug": "clelp-mcp-server",
"url": "https://glama.ai/mcp/servers/iu27vfrji2",
"repository": { "url": "https://github.com/..." },
"spdxLicense": { "name": "MIT License", "url": "..." },
"tools": [],
"attributes": [],
"environmentVariablesJsonSchema": null
}]
}
```
**Key differences from Official Registry**:
- NO install info (no `packages`, no `remotes`) → can't auto-install Glama-only servers
- Has `spdxLicense` → enrichment data
- Has `tools[]` → enrichment data
- Has `url` → link to Glama page
- Pagination: `after` param (not `cursor`)
**Auth**: No authentication required.
---
## 5. Marketplace JSON Schema
**URL**: `https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json`
```json
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "claude-plugins-official",
"description": "...",
"owner": { "name": "Anthropic", "email": "..." },
"plugins": [
{
"name": "typescript-lsp",
"description": "...",
"version": "1.0.0",
"author": { "name": "Anthropic", "email": "..." },
"source": "./plugins/typescript-lsp", // local path OR object with URL
"category": "development",
"strict": false,
"lspServers": { ... } // optional
}
]
}
```
**Plugin fields**:
- `name`: display/install name
- `source`: string (local path) or `{ source: "url", url: "..." }` (external)
- `category`: open-ended string
- `lspServers`: optional dict → hasLspServers capability
- No `mcpServers`, `agents`, `commands`, `hooks` found in current V1 marketplace
- `homepage`: optional string (external plugins)
- `tags`: NOT present in marketplace.json (will be empty in V1)
**Categories found**: database, deployment, design, development, learning, monitoring, productivity, security, testing
**ETag support**: Yes — `If-None-Match` → 304 Not Modified
---
## 6. Decisions
| Decision | Value |
|----------|-------|
| Scope | Electron-only V1 |
| Plugin identity key (`pluginId`) | `<name>@<marketplace>` = qualifiedName |
| Plugin install target | `qualifiedName` resolved by main from catalog |
| Plugin scope flag | `-s, --scope` (short) / `--scope` (long) |
| MCP scope flag | `-s, --scope` |
| MCP transport flag | `-t, --transport` |
| MCP env flag | `-e, --env KEY=val` |
| MCP header flag | `-H, --header "Key: val"`**SUPPORTED** |
| MCP default scope | `local` |
| Plugin default scope | `user` |
| Official Registry API version | `v0.1` |
| Official Registry pagination | cursor-based, `cursor` param |
| Glama pagination | cursor-based, `after` param |
| Latest version filter | `_meta.isLatest: true` |
| Capability flags V1 | `hasLspServers` only (others not in current marketplace) |
| Install counts key | plugin `name` (without `@marketplace`) |

View file

@ -65,6 +65,17 @@ import {
TeamProvisioningService, TeamProvisioningService,
UpdaterService, UpdaterService,
} from './services'; } from './services';
import {
ExtensionFacadeService,
GlamaMcpEnrichmentService,
McpCatalogAggregator,
McpInstallationStateService,
McpInstallService,
OfficialMcpRegistryService,
PluginCatalogService,
PluginInstallationStateService,
PluginInstallService,
} from './services/extensions';
import type { FileChangeEvent } from '@main/types'; import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types'; import type { TeamChangeEvent } from '@shared/types';
@ -431,9 +442,11 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Inbox change events: relay to lead + native OS notifications --- // --- Inbox change events: relay to lead + native OS notifications ---
if (row.type === 'inbox') { if (row.type === 'inbox') {
if (teamDataService) { if (teamDataService) {
void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) => void teamDataService
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`) .reconcileTeamArtifacts(teamName)
); .catch((e: unknown) =>
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
);
} }
// Auto-relay ONLY lead-inbox changes into the live lead process. // Auto-relay ONLY lead-inbox changes into the live lead process.
@ -485,9 +498,11 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Task change events: notify lead when teammate starts a task via CLI --- // --- Task change events: notify lead when teammate starts a task via CLI ---
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) { if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) => void teamDataService
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`) .reconcileTeamArtifacts(teamName)
); .catch((e: unknown) =>
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
);
const taskId = detail.replace('.json', ''); const taskId = detail.replace('.json', '');
void teamDataService void teamDataService
@ -648,6 +663,24 @@ function initializeServices(): void {
const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback); const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback);
const reviewApplier = new ReviewApplierService(); const reviewApplier = new ReviewApplierService();
// Extension Store services
const pluginCatalogService = new PluginCatalogService();
const pluginStateService = new PluginInstallationStateService();
const officialMcpRegistry = new OfficialMcpRegistryService();
const glamaMcpService = new GlamaMcpEnrichmentService();
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
const mcpStateService = new McpInstallationStateService();
const extensionFacadeService = new ExtensionFacadeService(
pluginCatalogService,
pluginStateService,
mcpAggregator,
mcpStateService
);
// Install services — pass null for binary (uses PATH lookup via execCli fallback)
const pluginInstallService = new PluginInstallService(null, pluginCatalogService);
const mcpInstallService = new McpInstallService(null, mcpAggregator);
// warmup() and ensureInstalled() are deferred to after window creation // warmup() and ensureInstalled() are deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup. // (did-finish-load handler) to avoid thread pool contention at startup.
httpServer = new HttpServer(); httpServer = new HttpServer();
@ -695,7 +728,10 @@ function initializeServices(): void {
reviewApplier, reviewApplier,
gitDiffFallback, gitDiffFallback,
cliInstallerService, cliInstallerService,
ptyTerminalService ptyTerminalService,
extensionFacadeService,
pluginInstallService,
mcpInstallService
); );
// Forward SSH state changes to renderer and HTTP SSE clients // Forward SSH state changes to renderer and HTTP SSE clients

280
src/main/ipc/extensions.ts Normal file
View file

@ -0,0 +1,280 @@
/**
* IPC handlers for Extension Store (plugin catalog + MCP registry).
*
* Phase 2: read-only plugin catalog (getAll, getReadme).
* Phase 3: read-only MCP registry (search, browse, getById, getInstalled).
* Phase 5: install/uninstall mutations.
*/
import { createLogger } from '@shared/utils/logger';
import type {
EnrichedPlugin,
InstalledMcpEntry,
McpCatalogItem,
McpInstallRequest,
McpSearchResult,
OperationResult,
PluginInstallRequest,
} from '@shared/types/extensions';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
import {
MCP_REGISTRY_BROWSE,
MCP_REGISTRY_GET_BY_ID,
MCP_REGISTRY_GET_INSTALLED,
MCP_REGISTRY_INSTALL,
MCP_REGISTRY_SEARCH,
MCP_REGISTRY_UNINSTALL,
PLUGIN_GET_ALL,
PLUGIN_GET_README,
PLUGIN_INSTALL,
PLUGIN_UNINSTALL,
} from '@preload/constants/ipcChannels';
const logger = createLogger('IPC:extensions');
/** Allowed scope values */
const VALID_SCOPES = new Set(['local', 'user', 'project']);
// ── Module state ───────────────────────────────────────────────────────────
let extensionFacade: ExtensionFacadeService | null = null;
let pluginInstaller: PluginInstallService | null = null;
let mcpInstaller: McpInstallService | null = null;
// ── Lifecycle ──────────────────────────────────────────────────────────────
export function initializeExtensionHandlers(
facade: ExtensionFacadeService,
pluginInstall?: PluginInstallService,
mcpInstall?: McpInstallService
): void {
extensionFacade = facade;
pluginInstaller = pluginInstall ?? null;
mcpInstaller = mcpInstall ?? null;
}
export function registerExtensionHandlers(ipcMain: IpcMain): void {
ipcMain.handle(PLUGIN_GET_ALL, handleGetAll);
ipcMain.handle(PLUGIN_GET_README, handleGetReadme);
ipcMain.handle(PLUGIN_INSTALL, handlePluginInstall);
ipcMain.handle(PLUGIN_UNINSTALL, handlePluginUninstall);
ipcMain.handle(MCP_REGISTRY_SEARCH, handleMcpSearch);
ipcMain.handle(MCP_REGISTRY_BROWSE, handleMcpBrowse);
ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById);
ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled);
ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall);
ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall);
}
export function removeExtensionHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(PLUGIN_GET_ALL);
ipcMain.removeHandler(PLUGIN_GET_README);
ipcMain.removeHandler(PLUGIN_INSTALL);
ipcMain.removeHandler(PLUGIN_UNINSTALL);
ipcMain.removeHandler(MCP_REGISTRY_SEARCH);
ipcMain.removeHandler(MCP_REGISTRY_BROWSE);
ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID);
ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED);
ipcMain.removeHandler(MCP_REGISTRY_INSTALL);
ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL);
}
// ── Service guard ──────────────────────────────────────────────────────────
function getFacade(): ExtensionFacadeService {
if (!extensionFacade) {
throw new Error('Extension handlers are not initialized');
}
return extensionFacade;
}
// ── Error wrapper ──────────────────────────────────────────────────────────
interface IpcResult<T> {
success: boolean;
data?: T;
error?: string;
}
async function wrapHandler<T>(operation: string, handler: () => Promise<T>): Promise<IpcResult<T>> {
try {
const data = await handler();
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`[extensions:${operation}] ${message}`);
return { success: false, error: message };
}
}
// ── Plugin Handlers ────────────────────────────────────────────────────────
async function handleGetAll(
_event: IpcMainInvokeEvent,
projectPath?: string,
forceRefresh?: boolean
): Promise<IpcResult<EnrichedPlugin[]>> {
return wrapHandler('getAll', () =>
getFacade().getEnrichedPlugins(
typeof projectPath === 'string' ? projectPath : undefined,
typeof forceRefresh === 'boolean' ? forceRefresh : false
)
);
}
async function handleGetReadme(
_event: IpcMainInvokeEvent,
pluginId?: string
): Promise<IpcResult<string | null>> {
return wrapHandler('getReadme', () => {
if (typeof pluginId !== 'string' || !pluginId) {
throw new Error('pluginId is required');
}
return getFacade().getPluginReadme(pluginId);
});
}
// ── MCP Handlers ───────────────────────────────────────────────────────────
async function handleMcpSearch(
_event: IpcMainInvokeEvent,
query?: string,
limit?: number
): Promise<IpcResult<McpSearchResult>> {
return wrapHandler('mcpSearch', () =>
getFacade().searchMcp(
typeof query === 'string' ? query : '',
typeof limit === 'number' ? limit : undefined
)
);
}
async function handleMcpBrowse(
_event: IpcMainInvokeEvent,
cursor?: string,
limit?: number
): Promise<IpcResult<{ servers: McpCatalogItem[]; nextCursor?: string }>> {
return wrapHandler('mcpBrowse', () =>
getFacade().browseMcp(
typeof cursor === 'string' ? cursor : undefined,
typeof limit === 'number' ? limit : undefined
)
);
}
async function handleMcpGetById(
_event: IpcMainInvokeEvent,
registryId?: string
): Promise<IpcResult<McpCatalogItem | null>> {
return wrapHandler('mcpGetById', () => {
if (typeof registryId !== 'string' || !registryId) {
throw new Error('registryId is required');
}
return getFacade().getMcpById(registryId);
});
}
async function handleMcpGetInstalled(
_event: IpcMainInvokeEvent,
projectPath?: string
): Promise<IpcResult<InstalledMcpEntry[]>> {
return wrapHandler('mcpGetInstalled', () =>
getFacade().getInstalledMcp(typeof projectPath === 'string' ? projectPath : undefined)
);
}
// ── Install/Uninstall Handlers ────────────────────────────────────────────
function getPluginInstaller(): PluginInstallService {
if (!pluginInstaller) {
throw new Error('Plugin installer not initialized');
}
return pluginInstaller;
}
function getMcpInstaller(): McpInstallService {
if (!mcpInstaller) {
throw new Error('MCP installer not initialized');
}
return mcpInstaller;
}
async function handlePluginInstall(
_event: IpcMainInvokeEvent,
request?: PluginInstallRequest
): Promise<IpcResult<OperationResult>> {
return wrapHandler('pluginInstall', () => {
if (!request || typeof request.pluginId !== 'string' || !request.pluginId) {
throw new Error('Invalid install request: pluginId is required');
}
if (request.scope && !VALID_SCOPES.has(request.scope)) {
throw new Error(`Invalid scope: "${request.scope}"`);
}
return getPluginInstaller().install(request);
});
}
async function handlePluginUninstall(
_event: IpcMainInvokeEvent,
pluginId?: string,
scope?: string,
projectPath?: string
): Promise<IpcResult<OperationResult>> {
return wrapHandler('pluginUninstall', () => {
if (typeof pluginId !== 'string' || !pluginId) {
throw new Error('pluginId is required');
}
if (scope && !VALID_SCOPES.has(scope)) {
throw new Error(`Invalid scope: "${scope}"`);
}
return getPluginInstaller().uninstall(
pluginId,
typeof scope === 'string' ? scope : undefined,
typeof projectPath === 'string' ? projectPath : undefined
);
});
}
async function handleMcpInstall(
_event: IpcMainInvokeEvent,
request?: McpInstallRequest
): Promise<IpcResult<OperationResult>> {
return wrapHandler('mcpInstall', () => {
if (!request || typeof request.registryId !== 'string' || !request.registryId) {
throw new Error('Invalid install request: registryId is required');
}
if (typeof request.serverName !== 'string' || !request.serverName) {
throw new Error('Invalid install request: serverName is required');
}
if (request.scope && !VALID_SCOPES.has(request.scope)) {
throw new Error(`Invalid scope: "${request.scope}"`);
}
return getMcpInstaller().install(request);
});
}
async function handleMcpUninstall(
_event: IpcMainInvokeEvent,
name?: string,
scope?: string,
projectPath?: string
): Promise<IpcResult<OperationResult>> {
return wrapHandler('mcpUninstall', () => {
if (typeof name !== 'string' || !name) {
throw new Error('Server name is required');
}
if (scope && !VALID_SCOPES.has(scope)) {
throw new Error(`Invalid scope: "${scope}"`);
}
return getMcpInstaller().uninstall(
name,
typeof scope === 'string' ? scope : undefined,
typeof projectPath === 'string' ? projectPath : undefined
);
});
}

View file

@ -29,6 +29,11 @@ import {
removeContextHandlers, removeContextHandlers,
} from './context'; } from './context';
import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor'; import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor';
import {
initializeExtensionHandlers,
registerExtensionHandlers,
removeExtensionHandlers,
} from './extensions';
import { import {
initializeHttpServerHandlers, initializeHttpServerHandlers,
registerHttpServerHandlers, registerHttpServerHandlers,
@ -88,6 +93,9 @@ import type {
UpdaterService, UpdaterService,
} from '../services'; } from '../services';
import type { HttpServer } from '../services/infrastructure/HttpServer'; import type { HttpServer } from '../services/infrastructure/HttpServer';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
/** /**
* Initializes IPC handlers with service registry. * Initializes IPC handlers with service registry.
@ -114,7 +122,10 @@ export function initializeIpcHandlers(
reviewApplier?: ReviewApplierService, reviewApplier?: ReviewApplierService,
gitDiffFallback?: GitDiffFallback, gitDiffFallback?: GitDiffFallback,
cliInstaller?: CliInstallerService, cliInstaller?: CliInstallerService,
ptyTerminal?: PtyTerminalService ptyTerminal?: PtyTerminalService,
extensionFacade?: ExtensionFacadeService,
pluginInstaller?: PluginInstallService,
mcpInstaller?: McpInstallService
): void { ): void {
// Initialize domain handlers with registry // Initialize domain handlers with registry
initializeProjectHandlers(registry); initializeProjectHandlers(registry);
@ -147,6 +158,10 @@ export function initializeIpcHandlers(
} }
initializeEditorHandlers(); initializeEditorHandlers();
if (extensionFacade) {
initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller);
}
if (changeExtractor) { if (changeExtractor) {
initializeReviewHandlers({ initializeReviewHandlers({
extractor: changeExtractor, extractor: changeExtractor,
@ -182,6 +197,9 @@ export function initializeIpcHandlers(
if (httpServerDeps) { if (httpServerDeps) {
registerHttpServerHandlers(ipcMain); registerHttpServerHandlers(ipcMain);
} }
if (extensionFacade) {
registerExtensionHandlers(ipcMain);
}
logger.info('All handlers registered'); logger.info('All handlers registered');
} }
@ -210,6 +228,7 @@ export function removeIpcHandlers(): void {
removeCliInstallerHandlers(ipcMain); removeCliInstallerHandlers(ipcMain);
removeTerminalHandlers(ipcMain); removeTerminalHandlers(ipcMain);
removeHttpServerHandlers(ipcMain); removeHttpServerHandlers(ipcMain);
removeExtensionHandlers(ipcMain);
logger.info('All handlers removed'); logger.info('All handlers removed');
} }

View file

@ -0,0 +1,134 @@
/**
* Facade service that combines plugin catalog + MCP catalog + installation state
* into enriched data ready for the renderer.
*
* Also provides install target resolution for the security model
* (main-side re-resolution: renderer sends pluginId/registryId, main resolves from catalog).
*/
import { createLogger } from '@shared/utils/logger';
import type {
EnrichedPlugin,
InstalledMcpEntry,
McpCatalogItem,
McpSearchResult,
PluginCatalogItem,
} from '@shared/types/extensions';
import { PluginCatalogService } from './catalog/PluginCatalogService';
import { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
import { PluginInstallationStateService } from './state/PluginInstallationStateService';
import { McpInstallationStateService } from './state/McpInstallationStateService';
const logger = createLogger('Extensions:Facade');
export class ExtensionFacadeService {
constructor(
private readonly pluginCatalog: PluginCatalogService,
private readonly pluginState: PluginInstallationStateService,
private readonly mcpAggregator: McpCatalogAggregator | null = null,
private readonly mcpState: McpInstallationStateService | null = null
) {}
// ── Plugin methods ───────────────────────────────────────────────────
/**
* Get all plugins enriched with install status and counts.
*/
async getEnrichedPlugins(projectPath?: string, forceRefresh = false): Promise<EnrichedPlugin[]> {
const [catalog, installed, counts] = await Promise.all([
this.pluginCatalog.getPlugins(forceRefresh),
this.pluginState.getInstalledPlugins(projectPath),
this.pluginState.getInstallCounts(),
]);
// Build installed lookup: pluginId → entries[]
const installedMap = new Map<string, typeof installed>();
for (const entry of installed) {
const list = installedMap.get(entry.pluginId) ?? [];
list.push(entry);
installedMap.set(entry.pluginId, list);
}
return catalog.map((item): EnrichedPlugin => {
const installations = installedMap.get(item.pluginId) ?? [];
const installCount = counts.get(item.pluginId) ?? 0;
return {
...item,
installCount,
isInstalled: installations.length > 0,
installations,
};
});
}
/**
* Get README content for a plugin.
*/
async getPluginReadme(pluginId: string): Promise<string | null> {
return this.pluginCatalog.getPluginReadme(pluginId);
}
/**
* Resolve a pluginId to its install target.
*/
async resolvePluginInstallTarget(
pluginId: string
): Promise<{ qualifiedName: string; plugin: PluginCatalogItem } | null> {
const plugin = await this.pluginCatalog.resolvePlugin(pluginId);
if (!plugin) {
logger.warn(`Cannot resolve install target: pluginId "${pluginId}" not found in catalog`);
return null;
}
return { qualifiedName: plugin.qualifiedName, plugin };
}
// ── MCP methods ──────────────────────────────────────────────────────
/**
* Search MCP servers across both registries.
*/
async searchMcp(query: string, limit?: number): Promise<McpSearchResult> {
if (!this.mcpAggregator) {
return { servers: [], warnings: ['MCP catalog not configured'] };
}
return this.mcpAggregator.search(query, limit);
}
/**
* Browse MCP catalog with pagination.
*/
async browseMcp(
cursor?: string,
limit?: number
): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
if (!this.mcpAggregator) {
return { servers: [] };
}
return this.mcpAggregator.browse(cursor, limit);
}
/**
* Get a single MCP server by registry ID (for install flow).
*/
async getMcpById(registryId: string): Promise<McpCatalogItem | null> {
if (!this.mcpAggregator) return null;
return this.mcpAggregator.getById(registryId);
}
/**
* Get installed MCP servers.
*/
async getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
if (!this.mcpState) return [];
return this.mcpState.getInstalled(projectPath);
}
// ── Cache invalidation ───────────────────────────────────────────────
invalidateInstalledCache(): void {
this.pluginState.invalidateCache();
this.mcpState?.invalidateCache();
}
}

View file

@ -0,0 +1,176 @@
/**
* Fetches MCP server data from the Glama.ai API.
*
* Optional enrichment layer NOT a hard dependency.
* Provides: license, tools, Glama URL.
* Does NOT provide install info (no packages/remotes).
*
* Base URL: https://glama.ai/api/mcp/v1/servers
* Cursor-based pagination (after), no auth required.
*/
import https from 'node:https';
import http from 'node:http';
import { createLogger } from '@shared/utils/logger';
import type { McpCatalogItem, McpToolDef } from '@shared/types/extensions';
const logger = createLogger('Extensions:GlamaMcp');
// ── Constants ──────────────────────────────────────────────────────────────
const GLAMA_BASE_URL = 'https://glama.ai/api/mcp/v1/servers';
const HTTP_TIMEOUT_MS = 15_000;
const MAX_REDIRECTS = 5;
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
// ── HTTP helper ────────────────────────────────────────────────────────────
function httpGet(
url: string,
redirectsLeft = MAX_REDIRECTS
): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'http:' ? http : https;
let settled = false;
const settleResolve = (v: { statusCode: number; body: string }): void => {
if (!settled) {
settled = true;
resolve(v);
}
};
const settleReject = (e: Error): void => {
if (!settled) {
settled = true;
reject(e);
}
};
const req = transport.get(url, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.destroy();
settleReject(new Error('Too many redirects'));
return;
}
res.destroy();
httpGet(new URL(res.headers.location, url).toString(), redirectsLeft - 1).then(
settleResolve,
settleReject
);
return;
}
const chunks: Buffer[] = [];
let totalSize = 0;
res.on('data', (c: Buffer) => {
totalSize += c.length;
if (totalSize > MAX_BODY_SIZE) {
res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
return;
}
chunks.push(c);
});
res.on('end', () =>
settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
);
res.on('error', settleReject);
});
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout fetching ${url}`)));
req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
});
}
// ── Raw Glama API shapes ───────────────────────────────────────────────────
interface GlamaResponse {
pageInfo: {
endCursor?: string;
hasNextPage?: boolean;
};
servers: GlamaServer[];
}
interface GlamaServer {
id: string;
name: string;
namespace?: string;
description?: string;
slug?: string;
url?: string;
repository?: { url: string };
spdxLicense?: { name: string; url?: string } | null;
tools?: { name?: string; description?: string }[];
}
// ── Service ────────────────────────────────────────────────────────────────
export class GlamaMcpEnrichmentService {
/**
* Search Glama for MCP servers.
*/
async search(query: string, limit = 20): Promise<McpCatalogItem[]> {
const params = new URLSearchParams({ search: query, first: String(limit) });
const url = `${GLAMA_BASE_URL}?${params}`;
try {
const resp = await httpGet(url);
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
const json = JSON.parse(resp.body) as GlamaResponse;
return json.servers.map((s) => this.normalize(s));
} catch (err) {
logger.warn('Glama MCP search failed:', err);
return [];
}
}
/**
* Browse Glama catalog with cursor pagination.
*/
async browse(
cursor?: string,
limit = 20
): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
const params = new URLSearchParams({ first: String(limit) });
if (cursor) params.set('after', cursor);
const url = `${GLAMA_BASE_URL}?${params}`;
try {
const resp = await httpGet(url);
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
const json = JSON.parse(resp.body) as GlamaResponse;
return {
servers: json.servers.map((s) => this.normalize(s)),
nextCursor: json.pageInfo.hasNextPage ? json.pageInfo.endCursor : undefined,
};
} catch (err) {
logger.warn('Glama MCP browse failed:', err);
return { servers: [] };
}
}
// ── Private ────────────────────────────────────────────────────────────
private normalize(raw: GlamaServer): McpCatalogItem {
const tools: McpToolDef[] = (raw.tools ?? [])
.filter((t): t is { name: string; description: string } => Boolean(t.name))
.map((t) => ({ name: t.name, description: t.description ?? '' }));
return {
id: `glama:${raw.id}`,
name: raw.name,
description: raw.description ?? '',
repositoryUrl: raw.repository?.url,
version: undefined, // Glama doesn't expose version
source: 'glama',
installSpec: null, // Glama has NO install info
envVars: [],
license: raw.spdxLicense?.name,
tools,
glamaUrl: raw.url,
requiresAuth: false,
};
}
}

View file

@ -0,0 +1,147 @@
/**
* Aggregates MCP catalog data from Official Registry + Glama.
*
* - Uses Promise.allSettled so partial API failures don't break the whole catalog
* - Dedup by repository URL; Official source takes priority
* - Enriches Official entries with Glama data (license, tools) when matched
* - Provides getById() for secure install flow
*/
import { createLogger } from '@shared/utils/logger';
import type { McpCatalogItem, McpSearchResult } from '@shared/types/extensions';
import { normalizeRepoUrl } from '@shared/utils/extensionNormalizers';
import { OfficialMcpRegistryService } from './OfficialMcpRegistryService';
import { GlamaMcpEnrichmentService } from './GlamaMcpEnrichmentService';
const logger = createLogger('Extensions:McpAggregator');
export class McpCatalogAggregator {
constructor(
private readonly official: OfficialMcpRegistryService,
private readonly glama: GlamaMcpEnrichmentService
) {}
/**
* Search both sources and return merged results.
*/
async search(query: string, limit = 20): Promise<McpSearchResult> {
const warnings: string[] = [];
const [officialResult, glamaResult] = await Promise.allSettled([
this.official.search(query, limit),
this.glama.search(query, limit),
]);
const officialServers = officialResult.status === 'fulfilled' ? officialResult.value : [];
const glamaServers = glamaResult.status === 'fulfilled' ? glamaResult.value : [];
if (officialResult.status === 'rejected') {
warnings.push('Official MCP Registry unavailable');
logger.warn('Official registry search failed:', officialResult.reason);
}
if (glamaResult.status === 'rejected') {
warnings.push('Glama enrichment unavailable');
logger.warn('Glama search failed:', glamaResult.reason);
}
const merged = this.mergeAndDeduplicate(officialServers, glamaServers);
return { servers: merged, warnings };
}
/**
* Browse the official registry with optional Glama enrichment.
*/
async browse(
cursor?: string,
limit = 20
): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
// Browse primarily from official registry (has pagination)
const result = await this.official.browse(cursor, limit);
// Optionally enrich with Glama data (best effort, no pagination sync)
try {
const glamaBrowse = await this.glama.browse(undefined, limit);
const enriched = this.enrichOfficialWithGlama(result.servers, glamaBrowse.servers);
return { servers: enriched, nextCursor: result.nextCursor };
} catch {
return result;
}
}
/**
* Get a single server by ID for secure install flow.
* Delegates to appropriate source based on ID prefix.
*/
async getById(registryId: string): Promise<McpCatalogItem | null> {
// Glama IDs are prefixed with "glama:"
if (registryId.startsWith('glama:')) {
logger.warn(`Cannot install Glama-only server: ${registryId}`);
return null; // Glama servers can't be auto-installed
}
// Official registry lookup
return this.official.getById(registryId);
}
// ── Private ────────────────────────────────────────────────────────────
/**
* Merge Official + Glama, dedup by repository URL.
* Official entries take priority.
*/
private mergeAndDeduplicate(
official: McpCatalogItem[],
glama: McpCatalogItem[]
): McpCatalogItem[] {
// Build repo URL index from official entries
const officialRepoUrls = new Set<string>();
for (const item of official) {
if (item.repositoryUrl) {
officialRepoUrls.add(normalizeRepoUrl(item.repositoryUrl));
}
}
// Enrich official entries with Glama data
const enriched = this.enrichOfficialWithGlama(official, glama);
// Add Glama-only entries (no matching official entry)
const glamaOnly = glama.filter((g) => {
if (!g.repositoryUrl) return true; // no repo URL = can't match, show separately
return !officialRepoUrls.has(normalizeRepoUrl(g.repositoryUrl));
});
return [...enriched, ...glamaOnly];
}
/**
* Enrich official entries with Glama metadata (license, tools, glamaUrl).
*/
private enrichOfficialWithGlama(
official: McpCatalogItem[],
glama: McpCatalogItem[]
): McpCatalogItem[] {
// Index Glama by normalized repo URL
const glamaByRepo = new Map<string, McpCatalogItem>();
for (const g of glama) {
if (g.repositoryUrl) {
glamaByRepo.set(normalizeRepoUrl(g.repositoryUrl), g);
}
}
return official.map((item) => {
if (!item.repositoryUrl) return item;
const glamaMatch = glamaByRepo.get(normalizeRepoUrl(item.repositoryUrl));
if (!glamaMatch) return item;
return {
...item,
license: item.license ?? glamaMatch.license,
tools: item.tools.length > 0 ? item.tools : glamaMatch.tools,
glamaUrl: glamaMatch.glamaUrl,
};
});
}
}

View file

@ -0,0 +1,335 @@
/**
* Fetches and normalizes MCP servers from the Official MCP Registry.
*
* Base URL: https://registry.modelcontextprotocol.io/v0.1/servers
* Cursor-based pagination, no auth required.
* Filters for _meta.isLatest to pick only latest versions.
*/
import https from 'node:https';
import http from 'node:http';
import { createLogger } from '@shared/utils/logger';
import type { McpCatalogItem, McpEnvVarDef, McpInstallSpec } from '@shared/types/extensions';
const logger = createLogger('Extensions:OfficialMcpRegistry');
// ── Constants ──────────────────────────────────────────────────────────────
const REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0.1/servers';
const HTTP_TIMEOUT_MS = 15_000;
const MAX_REDIRECTS = 5;
const CACHE_TTL_MS = 15 * 60_000; // 15 minutes
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
// ── HTTP helper ────────────────────────────────────────────────────────────
function httpGet(
url: string,
redirectsLeft = MAX_REDIRECTS
): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'http:' ? http : https;
let settled = false;
const settleResolve = (v: { statusCode: number; body: string }): void => {
if (!settled) {
settled = true;
resolve(v);
}
};
const settleReject = (e: Error): void => {
if (!settled) {
settled = true;
reject(e);
}
};
const req = transport.get(url, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.destroy();
settleReject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).toString();
res.destroy();
httpGet(redirectUrl, redirectsLeft - 1).then(settleResolve, settleReject);
return;
}
const chunks: Buffer[] = [];
let totalSize = 0;
res.on('data', (c: Buffer) => {
totalSize += c.length;
if (totalSize > MAX_BODY_SIZE) {
res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
return;
}
chunks.push(c);
});
res.on('end', () =>
settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
);
res.on('error', settleReject);
});
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout fetching ${url}`)));
req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
});
}
// ── Raw API response shapes ────────────────────────────────────────────────
interface RegistryResponse {
servers: RegistryServerEntry[];
metadata: { nextCursor?: string; count?: number };
}
interface RegistryIcon {
src: string;
mimeType?: string;
sizes?: string[];
theme?: 'light' | 'dark';
}
interface RegistryServerEntry {
server: {
name: string;
description?: string;
title?: string;
version?: string;
repository?: { url: string; source?: string };
websiteUrl?: string;
packages?: RegistryPackage[];
remotes?: RegistryRemote[];
icons?: RegistryIcon[];
};
_meta?: {
'io.modelcontextprotocol.registry/official'?: {
status?: string;
isLatest?: boolean;
};
};
}
interface RegistryPackage {
registryType: string;
identifier: string;
version?: string;
transport?: { type: string };
environmentVariables?: RegistryEnvVar[];
}
interface RegistryRemote {
type: string;
url: string;
headers?: RegistryHeader[];
}
interface RegistryHeader {
name: string;
description?: string;
isRequired?: boolean;
isSecret?: boolean;
value?: string;
}
interface RegistryEnvVar {
name: string;
description?: string;
isSecret?: boolean;
isRequired?: boolean;
}
// ── Cache ──────────────────────────────────────────────────────────────────
interface SearchCache {
key: string;
result: McpCatalogItem[];
fetchedAt: number;
}
// ── Service ────────────────────────────────────────────────────────────────
export class OfficialMcpRegistryService {
private searchCache: SearchCache | null = null;
/**
* Search the official registry by query text.
*/
async search(query: string, limit = 20): Promise<McpCatalogItem[]> {
const cacheKey = `search:${query}:${limit}`;
if (
this.searchCache?.key === cacheKey &&
Date.now() - this.searchCache.fetchedAt < CACHE_TTL_MS
) {
return this.searchCache.result;
}
const params = new URLSearchParams({ search: query, limit: String(limit) });
const url = `${REGISTRY_BASE_URL}?${params}`;
try {
const resp = await httpGet(url);
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
const json = JSON.parse(resp.body) as RegistryResponse;
const items = this.normalizeServers(json.servers);
this.searchCache = { key: cacheKey, result: items, fetchedAt: Date.now() };
return items;
} catch (err) {
logger.error('Official MCP Registry search failed:', err);
return this.searchCache?.result ?? [];
}
}
/**
* Browse the registry with cursor-based pagination.
*/
async browse(
cursor?: string,
limit = 20
): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set('cursor', cursor);
const url = `${REGISTRY_BASE_URL}?${params}`;
try {
const resp = await httpGet(url);
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
const json = JSON.parse(resp.body) as RegistryResponse;
return {
servers: this.normalizeServers(json.servers),
nextCursor: json.metadata.nextCursor,
};
} catch (err) {
logger.error('Official MCP Registry browse failed:', err);
return { servers: [] };
}
}
/**
* Get a single server by its registry ID (reverse-DNS name).
* Used for secure install flow (main-side re-resolution).
*/
async getById(registryId: string): Promise<McpCatalogItem | null> {
// The official registry search API can find by exact name
const params = new URLSearchParams({ search: registryId, limit: '5' });
const url = `${REGISTRY_BASE_URL}?${params}`;
try {
const resp = await httpGet(url);
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
const json = JSON.parse(resp.body) as RegistryResponse;
const items = this.normalizeServers(json.servers);
return items.find((s) => s.id === registryId) ?? null;
} catch (err) {
logger.error(`Official MCP Registry getById(${registryId}) failed:`, err);
return null;
}
}
// ── Private ────────────────────────────────────────────────────────────
private normalizeServers(entries: RegistryServerEntry[]): McpCatalogItem[] {
// Filter to isLatest only (same server name may appear multiple times)
const latest = entries.filter((e) => {
const meta = e._meta?.['io.modelcontextprotocol.registry/official'];
return meta?.isLatest !== false; // include if isLatest is true or undefined
});
// Deduplicate by server name (take first = latest version)
const seen = new Set<string>();
const unique: RegistryServerEntry[] = [];
for (const entry of latest) {
if (!seen.has(entry.server.name)) {
seen.add(entry.server.name);
unique.push(entry);
}
}
return unique.map((entry) => this.normalizeEntry(entry));
}
private normalizeEntry(entry: RegistryServerEntry): McpCatalogItem {
const { server } = entry;
const installSpec = this.deriveInstallSpec(server);
const envVars = this.collectEnvVars(server);
const requiresAuth = this.detectAuthRequired(server);
return {
id: server.name,
name: server.title ?? server.name.split('/').pop() ?? server.name,
description: server.description ?? '',
repositoryUrl: server.repository?.url,
version: server.version,
source: 'official',
installSpec,
envVars,
license: undefined, // Official registry doesn't expose license
tools: [], // Tools not included in registry list response
glamaUrl: undefined,
requiresAuth,
iconUrl: this.pickIconUrl(server.icons),
};
}
private deriveInstallSpec(server: RegistryServerEntry['server']): McpInstallSpec | null {
// Prefer npm stdio package
const npmPkg = server.packages?.find((p) => p.registryType === 'npm');
if (npmPkg) {
return {
type: 'stdio',
npmPackage: npmPkg.identifier,
npmVersion: npmPkg.version,
};
}
// HTTP/SSE remote
const remote = server.remotes?.[0];
if (remote) {
return {
type: 'http',
url: remote.url,
transportType: remote.type as 'streamable-http' | 'sse' | 'http',
};
}
return null;
}
private collectEnvVars(server: RegistryServerEntry['server']): McpEnvVarDef[] {
const envVars: McpEnvVarDef[] = [];
// From packages
for (const pkg of server.packages ?? []) {
for (const ev of pkg.environmentVariables ?? []) {
envVars.push({
name: ev.name,
isSecret: ev.isSecret ?? false,
description: ev.description,
isRequired: ev.isRequired,
});
}
}
return envVars;
}
private detectAuthRequired(server: RegistryServerEntry['server']): boolean {
for (const remote of server.remotes ?? []) {
for (const header of remote.headers ?? []) {
if (header.isRequired) return true;
}
}
return false;
}
/** Pick best icon URL from the registry icons array (prefer dark theme PNG). */
private pickIconUrl(icons?: RegistryIcon[]): string | undefined {
if (!icons || icons.length === 0) return undefined;
// Prefer dark-theme icon, then first available
const darkIcon = icons.find((i) => i.theme === 'dark');
return (darkIcon ?? icons[0]).src;
}
}

View file

@ -0,0 +1,318 @@
/**
* Fetches and caches the Claude Code plugin marketplace catalog.
*
* - Fetches marketplace.json from raw.githubusercontent.com
* - ETag + If-None-Match for bandwidth efficiency
* - In-memory cache with TTL (15 min)
* - Stale cache fallback on network error
* - Deduplicates concurrent requests
*/
import https from 'node:https';
import http from 'node:http';
import { createLogger } from '@shared/utils/logger';
import type { PluginCatalogItem } from '@shared/types/extensions';
import { buildPluginId } from '@shared/utils/extensionNormalizers';
const logger = createLogger('Extensions:PluginCatalog');
// ── Constants ──────────────────────────────────────────────────────────────
const MARKETPLACE_URL =
'https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json';
const CACHE_TTL_MS = 15 * 60 * 1_000; // 15 minutes
const HTTP_TIMEOUT_MS = 15_000; // 15 seconds
const MAX_REDIRECTS = 5;
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
const MAX_README_CACHE_SIZE = 50; // Max README entries to cache
// ── HTTP helpers (adapted from CliInstallerService) ────────────────────────
interface FetchOptions {
headers?: Record<string, string>;
}
interface FetchResponse {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: string;
}
function httpsGetFollowRedirects(
url: string,
options: FetchOptions = {},
redirectsLeft = MAX_REDIRECTS,
timeoutMs = HTTP_TIMEOUT_MS
): Promise<FetchResponse> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'http:' ? http : https;
let settled = false;
const settleResolve = (value: FetchResponse): void => {
if (settled) return;
settled = true;
resolve(value);
};
const settleReject = (err: Error): void => {
if (settled) return;
settled = true;
reject(err);
};
const reqOptions = {
headers: options.headers ?? {},
};
const req = transport.get(url, reqOptions, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.destroy();
settleReject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).toString();
res.destroy();
httpsGetFollowRedirects(redirectUrl, options, redirectsLeft - 1, timeoutMs).then(
settleResolve,
settleReject
);
return;
}
const chunks: Buffer[] = [];
let totalSize = 0;
res.on('data', (chunk: Buffer) => {
totalSize += chunk.length;
if (totalSize > MAX_BODY_SIZE) {
res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
return;
}
chunks.push(chunk);
});
res.on('end', () =>
settleResolve({
statusCode: status,
headers: res.headers as Record<string, string | string[] | undefined>,
body: Buffer.concat(chunks).toString('utf-8'),
})
);
res.on('error', settleReject);
});
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Connection timed out after ${timeoutMs}ms fetching ${url}`));
});
req.on('error', (err) => settleReject(err instanceof Error ? err : new Error(String(err))));
});
}
// ── Marketplace JSON shape ─────────────────────────────────────────────────
interface MarketplaceJson {
name: string;
plugins: RawMarketplacePlugin[];
}
interface RawMarketplacePlugin {
name: string;
description?: string;
version?: string;
category?: string;
author?: { name: string; email?: string };
source: string | { source: string; url: string; sha?: string };
strict?: boolean;
lspServers?: Record<string, unknown>;
mcpServers?: Record<string, unknown>;
agents?: Record<string, unknown>;
commands?: Record<string, unknown>;
hooks?: Record<string, unknown>;
}
// ── Cache ──────────────────────────────────────────────────────────────────
interface CatalogCache {
items: PluginCatalogItem[];
etag: string | null;
fetchedAt: number;
}
// ── Service ────────────────────────────────────────────────────────────────
export class PluginCatalogService {
private cache: CatalogCache | null = null;
private fetchInFlight: Promise<PluginCatalogItem[]> | null = null;
private readmeCache = new Map<string, { content: string | null; fetchedAt: number }>();
/**
* Get all plugins from the marketplace catalog.
* Uses in-memory cache with ETag validation.
*/
async getPlugins(forceRefresh = false): Promise<PluginCatalogItem[]> {
// Return cached if fresh and not forcing
if (!forceRefresh && this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
return this.cache.items;
}
// Deduplicate concurrent requests
if (this.fetchInFlight) {
return this.fetchInFlight;
}
this.fetchInFlight = this.fetchCatalog().finally(() => {
this.fetchInFlight = null;
});
return this.fetchInFlight;
}
/**
* Get README content for a plugin by its pluginId.
* For external plugins (source is URL), fetches README from the GitHub repo.
* Returns null for local/bundled plugins or on error.
*/
async getPluginReadme(pluginId: string): Promise<string | null> {
const cached = this.readmeCache.get(pluginId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.content;
}
// Need catalog to find the plugin's repo URL
const plugins = await this.getPlugins();
const plugin = plugins.find((p) => p.pluginId === pluginId);
if (!plugin?.homepage) {
this.setReadmeCache(pluginId, null);
return null;
}
const readmeUrl = this.buildReadmeUrl(plugin.homepage);
if (!readmeUrl) {
this.setReadmeCache(pluginId, null);
return null;
}
try {
const response = await httpsGetFollowRedirects(readmeUrl);
if (response.statusCode === 200) {
this.setReadmeCache(pluginId, response.body);
return response.body;
}
this.setReadmeCache(pluginId, null);
return null;
} catch (err) {
logger.warn(`Failed to fetch README for ${pluginId}:`, err);
this.setReadmeCache(pluginId, null);
return null;
}
}
/**
* Look up a single plugin by pluginId from the cached catalog.
* Used for main-side re-resolution during install.
*/
async resolvePlugin(pluginId: string): Promise<PluginCatalogItem | null> {
const plugins = await this.getPlugins();
return plugins.find((p) => p.pluginId === pluginId) ?? null;
}
// ── Private ────────────────────────────────────────────────────────────
/** Set readme cache with LRU eviction */
private setReadmeCache(pluginId: string, content: string | null): void {
// Evict oldest entries if at capacity
if (this.readmeCache.size >= MAX_README_CACHE_SIZE && !this.readmeCache.has(pluginId)) {
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.readmeCache) {
if (entry.fetchedAt < oldestTime) {
oldestTime = entry.fetchedAt;
oldestKey = key;
}
}
if (oldestKey) this.readmeCache.delete(oldestKey);
}
this.readmeCache.set(pluginId, { content, fetchedAt: Date.now() });
}
private async fetchCatalog(): Promise<PluginCatalogItem[]> {
const headers: Record<string, string> = {};
if (this.cache?.etag) {
headers['If-None-Match'] = this.cache.etag;
}
try {
const response = await httpsGetFollowRedirects(MARKETPLACE_URL, { headers });
// 304 Not Modified — cache is still valid
if (response.statusCode === 304 && this.cache) {
this.cache.fetchedAt = Date.now();
logger.info('Marketplace catalog not modified (304)');
return this.cache.items;
}
if (response.statusCode !== 200) {
throw new Error(`HTTP ${response.statusCode} fetching marketplace`);
}
const json = JSON.parse(response.body) as MarketplaceJson;
const items = this.parseMarketplace(json);
const etag = (response.headers['etag'] as string) ?? null;
this.cache = { items, etag, fetchedAt: Date.now() };
logger.info(`Fetched ${items.length} plugins from marketplace "${json.name}"`);
return items;
} catch (err) {
// Stale cache fallback
if (this.cache) {
logger.warn('Marketplace fetch failed, using stale cache:', err);
return this.cache.items;
}
logger.error('Marketplace fetch failed with no cache:', err);
throw err;
}
}
private parseMarketplace(json: MarketplaceJson): PluginCatalogItem[] {
const marketplaceName = json.name;
return json.plugins.map((raw): PluginCatalogItem => {
const qualifiedName = buildPluginId(raw.name, marketplaceName);
const isExternal = typeof raw.source === 'object';
const homepage = isExternal ? (raw.source as { url: string }).url : undefined;
return {
pluginId: qualifiedName,
marketplaceId: qualifiedName,
qualifiedName,
name: raw.name,
description: raw.description ?? '',
category: raw.category ?? 'other',
author: raw.author,
version: raw.version,
homepage: homepage?.replace(/\.git$/, ''),
tags: undefined,
hasLspServers: raw.lspServers != null && Object.keys(raw.lspServers).length > 0,
hasMcpServers: raw.mcpServers != null && Object.keys(raw.mcpServers).length > 0,
hasAgents: raw.agents != null && Object.keys(raw.agents).length > 0,
hasCommands: raw.commands != null && Object.keys(raw.commands).length > 0,
hasHooks: raw.hooks != null && Object.keys(raw.hooks).length > 0,
isExternal,
};
});
}
/**
* Build raw GitHub README URL from a GitHub repo URL.
* e.g. https://github.com/org/repo → https://raw.githubusercontent.com/org/repo/main/README.md
*/
private buildReadmeUrl(repoUrl: string): string | null {
const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!match) return null;
const [, owner, repo] = match;
return `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`;
}
}

View file

@ -0,0 +1,13 @@
/**
* Extension services barrel export.
*/
export { PluginCatalogService } from './catalog/PluginCatalogService';
export { OfficialMcpRegistryService } from './catalog/OfficialMcpRegistryService';
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
export { PluginInstallationStateService } from './state/PluginInstallationStateService';
export { McpInstallationStateService } from './state/McpInstallationStateService';
export { ExtensionFacadeService } from './ExtensionFacadeService';
export { PluginInstallService } from './install/PluginInstallService';
export { McpInstallService } from './install/McpInstallService';

View file

@ -0,0 +1,242 @@
/**
* McpInstallService installs/uninstalls MCP servers via Claude CLI.
*
* Security model: renderer sends ONLY registryId + user inputs (env values,
* headers, server name). Main re-fetches server spec from registry via getById()
* and builds CLI args from the fresh registry data (never trusts install spec
* from renderer).
*/
import { execCli } from '@main/utils/childProcess';
import { createLogger } from '@shared/utils/logger';
import type { McpInstallRequest, OperationResult } from '@shared/types/extensions';
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
const logger = createLogger('Extensions:McpInstall');
/** Validate server name: alphanumeric, dashes, underscores, dots */
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
/** Allowed scope values (prevent command injection) */
const VALID_SCOPES = new Set(['local', 'user', 'project']);
/** Env var key must be safe shell identifier */
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
/** HTTP header key must be safe (RFC 7230 token) */
const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/;
const TIMEOUT_MS = 30_000;
export class McpInstallService {
constructor(
private readonly claudeBinary: string | null,
private readonly aggregator: McpCatalogAggregator
) {}
async install(request: McpInstallRequest): Promise<OperationResult> {
const { registryId, serverName, scope, projectPath, envValues, headers } = request;
// 1. Validate server name
if (!SERVER_NAME_RE.test(serverName)) {
return {
state: 'error',
error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`,
};
}
// 2. Validate scope
if (scope && !VALID_SCOPES.has(scope)) {
return {
state: 'error',
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
};
}
// 3. Validate env var keys (prevent command injection)
for (const key of Object.keys(envValues)) {
if (!ENV_KEY_RE.test(key)) {
return {
state: 'error',
error: `Invalid environment variable name: "${key}". Use uppercase alphanumeric and underscores.`,
};
}
}
// 4. Validate header keys (prevent header injection)
for (const header of headers) {
if (header.key && !HEADER_KEY_RE.test(header.key)) {
return {
state: 'error',
error: `Invalid header name: "${header.key}". Use alphanumeric, dashes, underscores.`,
};
}
}
// 5. Validate projectPath (if provided, must be absolute)
if (projectPath && !projectPath.startsWith('/')) {
return {
state: 'error',
error: 'projectPath must be an absolute path',
};
}
// 6. Re-fetch from registry (don't trust renderer-provided install spec)
const server = await this.aggregator.getById(registryId);
if (!server) {
return {
state: 'error',
error: `MCP server "${registryId}" not found in registry`,
};
}
if (!server.installSpec) {
return {
state: 'error',
error: `MCP server "${server.name}" does not have an automatic install spec. Manual setup required.`,
};
}
// 7. Build CLI args based on install spec type
const args: string[] = ['mcp', 'add'];
// Scope flag (-s)
if (scope && scope !== 'local') {
args.push('-s', scope);
}
if (server.installSpec.type === 'stdio') {
// Stdio: claude mcp add [-s scope] [-e KEY=val...] <name> -- npx -y <package>[@version]
// Add env flags
for (const [key, value] of Object.entries(envValues)) {
if (key && value) {
args.push('-e', `${key}=${value}`);
}
}
args.push(serverName);
args.push('--');
args.push('npx', '-y');
const pkg = server.installSpec.npmVersion
? `${server.installSpec.npmPackage}@${server.installSpec.npmVersion}`
: server.installSpec.npmPackage;
args.push(pkg);
} else if (server.installSpec.type === 'http') {
// HTTP/SSE: claude mcp add [-s scope] -t <transport> [-H "Key: val"...] <name> <url>
const transport = server.installSpec.transportType === 'sse' ? 'sse' : 'http';
args.push('-t', transport);
// Add header flags
for (const header of headers) {
if (header.key && header.value) {
args.push('-H', `${header.key}: ${header.value}`);
}
}
// Add env flags (some HTTP servers also need env vars)
for (const [key, value] of Object.entries(envValues)) {
if (key && value) {
args.push('-e', `${key}=${value}`);
}
}
args.push(serverName);
args.push(server.installSpec.url);
} else {
return {
state: 'error',
error: `Unsupported install spec type: ${(server.installSpec as { type: string }).type}`,
};
}
logger.info(
`Installing MCP server: ${serverName} (type: ${server.installSpec.type}, scope: ${scope ?? 'local'})`
);
// Don't log env values or header values (may contain secrets)
try {
const { stderr } = await execCli(this.claudeBinary, args, {
timeout: TIMEOUT_MS,
cwd: projectPath,
});
if (stderr) {
logger.warn(`MCP install stderr: ${stderr.slice(0, 200)}`);
}
return { state: 'success' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Mask potential secrets in error output
const safeMessage = maskSecrets(
message,
envValues,
headers.map((h) => h.value)
);
logger.error(`MCP install failed: ${safeMessage}`);
return { state: 'error', error: safeMessage };
}
}
async uninstall(name: string, scope?: string, projectPath?: string): Promise<OperationResult> {
if (!SERVER_NAME_RE.test(name)) {
return {
state: 'error',
error: `Invalid server name: "${name}"`,
};
}
if (scope && !VALID_SCOPES.has(scope)) {
return {
state: 'error',
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
};
}
if (projectPath && !projectPath.startsWith('/')) {
return {
state: 'error',
error: 'projectPath must be an absolute path',
};
}
const args = ['mcp', 'remove'];
if (scope && scope !== 'local') {
args.push('-s', scope);
}
args.push(name);
logger.info(`Removing MCP server: ${name} (scope: ${scope ?? 'local'})`);
try {
await execCli(this.claudeBinary, args, {
timeout: TIMEOUT_MS,
cwd: projectPath,
});
return { state: 'success' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error(`MCP uninstall failed: ${message}`);
return { state: 'error', error: message };
}
}
}
/** Replace secret values in error messages with [REDACTED] */
function maskSecrets(
message: string,
envValues: Record<string, string>,
headerValues: string[]
): string {
let result = message;
const secrets = [
...Object.values(envValues).filter((v) => v.length > 3),
...headerValues.filter((v) => v.length > 3),
];
for (const secret of secrets) {
result = result.replaceAll(secret, '[REDACTED]');
}
return result;
}

View file

@ -0,0 +1,154 @@
/**
* PluginInstallService installs/uninstalls plugins via Claude CLI.
*
* Security model: renderer sends ONLY pluginId, main resolves qualifiedName
* from the current catalog snapshot (never trusts renderer-provided paths).
*/
import { execCli } from '@main/utils/childProcess';
import { createLogger } from '@shared/utils/logger';
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
const logger = createLogger('Extensions:PluginInstall');
/** Validate qualifiedName: must be <name>@<marketplace> with safe characters */
const QUALIFIED_NAME_RE = /^[\w.-]+@[\w.-]+$/;
/** Allowed scope values (prevent command injection) */
const VALID_SCOPES = new Set(['local', 'user', 'project']);
const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos
const UNINSTALL_TIMEOUT_MS = 30_000;
export class PluginInstallService {
constructor(
private readonly claudeBinary: string | null,
private readonly catalogService: PluginCatalogService
) {}
async install(request: PluginInstallRequest): Promise<OperationResult> {
const { pluginId, scope, projectPath } = request;
// 1. Validate scope
if (scope && !VALID_SCOPES.has(scope)) {
return {
state: 'error',
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
};
}
// 2. Validate projectPath
if (projectPath && !projectPath.startsWith('/')) {
return {
state: 'error',
error: 'projectPath must be an absolute path',
};
}
// 3. Resolve qualifiedName from catalog (NOT from renderer)
const resolved = await this.catalogService.resolvePlugin(pluginId);
if (!resolved) {
return {
state: 'error',
error: `Plugin "${pluginId}" not found in catalog`,
};
}
const { qualifiedName } = resolved;
// 2. Validate qualifiedName format (prevent injection)
if (!QUALIFIED_NAME_RE.test(qualifiedName)) {
return {
state: 'error',
error: `Invalid plugin identifier: ${qualifiedName}`,
};
}
// 5. Build CLI args: claude plugin install [-s scope] <qualifiedName>
const args = ['plugin', 'install'];
if (scope && scope !== 'user') {
args.push('-s', scope);
}
args.push(qualifiedName);
logger.info(`Installing plugin: ${qualifiedName} (scope: ${scope ?? 'user'})`);
try {
const { stdout, stderr } = await execCli(this.claudeBinary, args, {
timeout: INSTALL_TIMEOUT_MS,
cwd: projectPath,
});
if (stderr && !stdout) {
logger.warn(`Plugin install stderr: ${stderr}`);
}
return { state: 'success' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error(`Plugin install failed: ${message}`);
return { state: 'error', error: message };
}
}
async uninstall(
pluginId: string,
scope?: string,
projectPath?: string
): Promise<OperationResult> {
// Validate scope
if (scope && !VALID_SCOPES.has(scope)) {
return {
state: 'error',
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
};
}
if (projectPath && !projectPath.startsWith('/')) {
return {
state: 'error',
error: 'projectPath must be an absolute path',
};
}
// Resolve qualifiedName from catalog
const resolved = await this.catalogService.resolvePlugin(pluginId);
if (!resolved) {
return {
state: 'error',
error: `Plugin "${pluginId}" not found in catalog`,
};
}
const { qualifiedName } = resolved;
if (!QUALIFIED_NAME_RE.test(qualifiedName)) {
return {
state: 'error',
error: `Invalid plugin identifier: ${qualifiedName}`,
};
}
const args = ['plugin', 'uninstall'];
if (scope && scope !== 'user') {
args.push('-s', scope);
}
args.push(qualifiedName);
logger.info(`Uninstalling plugin: ${qualifiedName} (scope: ${scope ?? 'user'})`);
try {
await execCli(this.claudeBinary, args, {
timeout: UNINSTALL_TIMEOUT_MS,
cwd: projectPath,
});
return { state: 'success' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error(`Plugin uninstall failed: ${message}`);
return { state: 'error', error: message };
}
}
}

View file

@ -0,0 +1,105 @@
/**
* Reads installed MCP server state from the filesystem.
*
* Sources:
* - User scope: ~/.claude.json mcpServers
* - Project scope: .mcp.json in project root
* - Local scope: determined by Claude CLI (may also be in ~/.claude.json)
*
* Both files are managed by the Claude CLI. This service is read-only.
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { createLogger } from '@shared/utils/logger';
import type { InstalledMcpEntry } from '@shared/types/extensions';
import { getHomeDir } from '@main/utils/pathDecoder';
const logger = createLogger('Extensions:McpState');
const CACHE_TTL_MS = 10_000; // 10 seconds
interface TimedCache<T> {
data: T;
fetchedAt: number;
}
export class McpInstallationStateService {
private cache: TimedCache<InstalledMcpEntry[]> | null = null;
/**
* Get all installed MCP servers across user and project scopes.
*/
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
// Cache is project-path-dependent, so invalidate on path change
if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
return this.cache.data;
}
const entries: InstalledMcpEntry[] = [];
// User scope: ~/.claude.json
const userEntries = await this.readUserMcpServers();
entries.push(...userEntries);
// Project scope: .mcp.json
if (projectPath) {
const projectEntries = await this.readProjectMcpServers(projectPath);
entries.push(...projectEntries);
}
this.cache = { data: entries, fetchedAt: Date.now() };
return entries;
}
/**
* Invalidate cache. Call after install/uninstall operations.
*/
invalidateCache(): void {
this.cache = null;
}
// ── Private ────────────────────────────────────────────────────────────
private async readUserMcpServers(): Promise<InstalledMcpEntry[]> {
const configPath = path.join(getHomeDir(), '.claude.json');
return this.readMcpServersFromFile(configPath, 'user');
}
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
const configPath = path.join(projectPath, '.mcp.json');
return this.readMcpServersFromFile(configPath, 'project');
}
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>;
const mcpServers = json.mcpServers as
| Record<string, { command?: string; url?: string }>
| undefined;
if (!mcpServers || typeof mcpServers !== 'object') {
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 };
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
logger.error(`Failed to read MCP servers from ${filePath}:`, err);
return [];
}
}
}

View file

@ -0,0 +1,178 @@
/**
* Reads plugin installed state and install counts from the filesystem.
*
* Sources:
* - Installed state: ~/.claude/plugins/installed_plugins.json
* - Install counts: ~/.claude/plugins/install-counts-cache.json
*
* Both files are managed by the Claude CLI. This service is read-only.
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { createLogger } from '@shared/utils/logger';
import type { InstalledPluginEntry } from '@shared/types/extensions';
import type { InstallScope } from '@shared/types/extensions';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
const logger = createLogger('Extensions:PluginState');
// ── Constants ──────────────────────────────────────────────────────────────
const INSTALLED_STATE_TTL_MS = 10_000; // 10 seconds
const INSTALL_COUNTS_TTL_MS = 5 * 60_000; // 5 minutes
// ── Raw file shapes ────────────────────────────────────────────────────────
interface InstalledPluginsJson {
version: number;
plugins: Record<
string, // qualifiedName
Array<{
scope: string;
installPath?: string;
version?: string;
installedAt?: string;
lastUpdated?: string;
gitCommitSha?: string;
}>
>;
}
interface InstallCountsJson {
version: number;
fetchedAt: string;
counts: Array<{
plugin: string; // qualifiedName format
unique_installs: number;
}>;
}
// ── Cache ──────────────────────────────────────────────────────────────────
interface TimedCache<T> {
data: T;
fetchedAt: number;
}
// ── Service ────────────────────────────────────────────────────────────────
export class PluginInstallationStateService {
private installedCache: TimedCache<InstalledPluginEntry[]> | null = null;
private countsCache: TimedCache<Map<string, number>> | null = null;
/**
* Get all installed plugins across all scopes.
* Returns merged list from installed_plugins.json with scope tags.
*/
async getInstalledPlugins(_projectPath?: string): Promise<InstalledPluginEntry[]> {
if (
this.installedCache &&
Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS
) {
return this.installedCache.data;
}
const entries = await this.readInstalledPlugins();
this.installedCache = { data: entries, fetchedAt: Date.now() };
return entries;
}
/**
* Get install counts keyed by pluginId (qualifiedName).
*/
async getInstallCounts(): Promise<Map<string, number>> {
if (this.countsCache && Date.now() - this.countsCache.fetchedAt < INSTALL_COUNTS_TTL_MS) {
return this.countsCache.data;
}
const counts = await this.readInstallCounts();
this.countsCache = { data: counts, fetchedAt: Date.now() };
return counts;
}
/**
* Invalidate all caches. Call after install/uninstall operations.
*/
invalidateCache(): void {
this.installedCache = null;
this.countsCache = null;
}
// ── Private ────────────────────────────────────────────────────────────
private getPluginsDir(): string {
return path.join(getClaudeBasePath(), 'plugins');
}
private async readInstalledPlugins(): Promise<InstalledPluginEntry[]> {
const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json');
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as InstalledPluginsJson;
if (json.version !== 2 || !json.plugins) {
logger.warn(`Unexpected installed_plugins.json version: ${json.version}`);
return [];
}
const entries: InstalledPluginEntry[] = [];
for (const [qualifiedName, installations] of Object.entries(json.plugins)) {
for (const inst of installations) {
entries.push({
pluginId: qualifiedName,
scope: this.normalizeScope(inst.scope),
version: inst.version,
installedAt: inst.installedAt,
installPath: inst.installPath,
});
}
}
return entries;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return []; // No plugins installed yet
}
logger.error('Failed to read installed_plugins.json:', err);
return [];
}
}
private async readInstallCounts(): Promise<Map<string, number>> {
const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json');
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as InstallCountsJson;
const map = new Map<string, number>();
if (json.counts && Array.isArray(json.counts)) {
for (const entry of json.counts) {
// Install counts use qualifiedName format (name@marketplace)
map.set(entry.plugin, entry.unique_installs);
}
}
return map;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return new Map();
}
logger.error('Failed to read install-counts-cache.json:', err);
return new Map();
}
}
private normalizeScope(raw: string): InstallScope {
const lower = raw.toLowerCase();
if (lower === 'user' || lower === 'project' || lower === 'local') {
return lower;
}
return 'user'; // safe default
}
}

View file

@ -15,3 +15,4 @@ export * from './error';
export * from './infrastructure'; export * from './infrastructure';
export * from './parsing'; export * from './parsing';
export * from './team'; export * from './team';
export * from './extensions';

View file

@ -512,3 +512,41 @@ export const EDITOR_CHANGE = 'editor:change';
/** List project files by path (for @file mentions, independent of editor state) */ /** List project files by path (for @file mentions, independent of editor state) */
export const PROJECT_LIST_FILES = 'project:listFiles'; export const PROJECT_LIST_FILES = 'project:listFiles';
// =============================================================================
// Extensions / Plugin Catalog Channels
// =============================================================================
/** Get all enriched plugins (catalog + installed state + counts) */
export const PLUGIN_GET_ALL = 'plugin:getAll';
/** Get README content for a plugin by pluginId */
export const PLUGIN_GET_README = 'plugin:getReadme';
// =============================================================================
// Extensions / MCP Registry Channels
// =============================================================================
/** Search MCP servers across registries */
export const MCP_REGISTRY_SEARCH = 'mcpRegistry:search';
/** Browse MCP catalog with pagination */
export const MCP_REGISTRY_BROWSE = 'mcpRegistry:browse';
/** Get a single MCP server by registry ID */
export const MCP_REGISTRY_GET_BY_ID = 'mcpRegistry:getById';
/** Get installed MCP servers */
export const MCP_REGISTRY_GET_INSTALLED = 'mcpRegistry:getInstalled';
/** Install a plugin */
export const PLUGIN_INSTALL = 'plugin:install';
/** Uninstall a plugin */
export const PLUGIN_UNINSTALL = 'plugin:uninstall';
/** Install an MCP server */
export const MCP_REGISTRY_INSTALL = 'mcpRegistry:install';
/** Uninstall an MCP server */
export const MCP_REGISTRY_UNINSTALL = 'mcpRegistry:uninstall';

View file

@ -129,6 +129,16 @@ import {
WINDOW_IS_MAXIMIZED, WINDOW_IS_MAXIMIZED,
WINDOW_MAXIMIZE, WINDOW_MAXIMIZE,
WINDOW_MINIMIZE, WINDOW_MINIMIZE,
PLUGIN_GET_ALL,
PLUGIN_GET_README,
PLUGIN_INSTALL,
PLUGIN_UNINSTALL,
MCP_REGISTRY_SEARCH,
MCP_REGISTRY_BROWSE,
MCP_REGISTRY_GET_BY_ID,
MCP_REGISTRY_GET_INSTALLED,
MCP_REGISTRY_INSTALL,
MCP_REGISTRY_UNINSTALL,
} from './constants/ipcChannels'; } from './constants/ipcChannels';
import { import {
CONFIG_ADD_CUSTOM_PROJECT_PATH, CONFIG_ADD_CUSTOM_PROJECT_PATH,
@ -222,6 +232,15 @@ import type {
UpdateKanbanPatch, UpdateKanbanPatch,
WslClaudeRootCandidate, WslClaudeRootCandidate,
} from '@shared/types'; } from '@shared/types';
import type {
EnrichedPlugin,
InstalledMcpEntry,
McpCatalogItem,
McpInstallRequest,
McpSearchResult,
OperationResult,
PluginInstallRequest,
} from '@shared/types/extensions';
import type { import type {
BinaryPreviewResult, BinaryPreviewResult,
CreateDirResponse, CreateDirResponse,
@ -1253,6 +1272,38 @@ const electronAPI: ElectronAPI = {
}; };
}, },
}, },
// ===== Plugin Catalog API (Electron-only) =====
plugins: {
getAll: (projectPath?: string, forceRefresh?: boolean) =>
invokeIpcWithResult<EnrichedPlugin[]>(PLUGIN_GET_ALL, projectPath, forceRefresh),
getReadme: (pluginId: string) =>
invokeIpcWithResult<string | null>(PLUGIN_GET_README, pluginId),
install: (request: PluginInstallRequest) =>
invokeIpcWithResult<OperationResult>(PLUGIN_INSTALL, request),
uninstall: (pluginId: string, scope?: string, projectPath?: string) =>
invokeIpcWithResult<OperationResult>(PLUGIN_UNINSTALL, pluginId, scope, projectPath),
},
// ===== MCP Registry API (Electron-only) =====
mcpRegistry: {
search: (query: string, limit?: number) =>
invokeIpcWithResult<McpSearchResult>(MCP_REGISTRY_SEARCH, query, limit),
browse: (cursor?: string, limit?: number) =>
invokeIpcWithResult<{ servers: McpCatalogItem[]; nextCursor?: string }>(
MCP_REGISTRY_BROWSE,
cursor,
limit
),
getById: (registryId: string) =>
invokeIpcWithResult<McpCatalogItem | null>(MCP_REGISTRY_GET_BY_ID, registryId),
getInstalled: (projectPath?: string) =>
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
install: (request: McpInstallRequest) =>
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
uninstall: (name: string, scope?: string, projectPath?: string) =>
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_UNINSTALL, name, scope, projectPath),
},
}; };
// Use contextBridge to securely expose the API to the renderer process // Use contextBridge to securely expose the API to the renderer process

View file

@ -0,0 +1,134 @@
/**
* ExtensionStoreView top-level component for the Extensions tab.
* Uses per-tab UI state via useExtensionsTabState() hook.
* Global catalog data comes from Zustand store.
*/
import { useCallback, useEffect } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { Puzzle, RefreshCw, Server } from 'lucide-react';
import { McpServersPanel } from './mcp/McpServersPanel';
import { PluginsPanel } from './plugins/PluginsPanel';
export const ExtensionStoreView = (): React.JSX.Element => {
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
const mcpBrowse = useStore((s) => s.mcpBrowse);
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
const tabState = useExtensionsTabState();
// Fetch plugin catalog on mount
useEffect(() => {
void fetchPluginCatalog();
}, [fetchPluginCatalog]);
// Fetch MCP installed state on mount
useEffect(() => {
void mcpFetchInstalled();
}, [mcpFetchInstalled]);
// Refresh all data (plugins + MCP browse + installed)
const handleRefresh = useCallback(() => {
void fetchPluginCatalog(undefined, true);
void mcpBrowse(); // re-fetch first page
void mcpFetchInstalled();
}, [fetchPluginCatalog, mcpBrowse, mcpFetchInstalled]);
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading;
// Browser mode guard
if (!api.plugins && !api.mcpRegistry) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<Puzzle className="mx-auto mb-3 size-12 text-text-muted" />
<h2 className="text-lg font-semibold text-text">Extensions</h2>
<p className="mt-1 text-sm text-text-muted">Available in the desktop app only.</p>
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<Puzzle className="size-5 text-text-muted" />
<h1 className="text-lg font-semibold text-text">Extensions</h1>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={`size-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh catalog</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Sub-tabs */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<Tabs
value={tabState.activeSubTab}
onValueChange={(v) => tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')}
>
<TabsList className="mb-4">
<TabsTrigger value="plugins" className="gap-1.5">
<Puzzle className="size-3.5" />
Plugins
</TabsTrigger>
<TabsTrigger value="mcp-servers" className="gap-1.5">
<Server className="size-3.5" />
MCP Servers
</TabsTrigger>
</TabsList>
<TabsContent value="plugins">
<PluginsPanel
pluginFilters={tabState.pluginFilters}
pluginSort={tabState.pluginSort}
selectedPluginId={tabState.selectedPluginId}
updatePluginSearch={tabState.updatePluginSearch}
toggleCategory={tabState.toggleCategory}
toggleCapability={tabState.toggleCapability}
toggleInstalledOnly={tabState.toggleInstalledOnly}
setSelectedPluginId={tabState.setSelectedPluginId}
clearFilters={tabState.clearFilters}
hasActiveFilters={tabState.hasActiveFilters}
setPluginSort={tabState.setPluginSort}
/>
</TabsContent>
<TabsContent value="mcp-servers">
<McpServersPanel
mcpSearchQuery={tabState.mcpSearchQuery}
mcpSearch={tabState.mcpSearch}
mcpSearchResults={tabState.mcpSearchResults}
mcpSearchLoading={tabState.mcpSearchLoading}
mcpSearchWarnings={tabState.mcpSearchWarnings}
selectedMcpServerId={tabState.selectedMcpServerId}
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
/>
</TabsContent>
</Tabs>
</div>
</div>
);
};

View file

@ -0,0 +1,96 @@
/**
* InstallButton animated install/uninstall button for extensions.
* States: idle pending (spinner) success (checkmark, 2s) idle
*/
import { Check, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@renderer/components/ui/button';
import type { ExtensionOperationState } from '@shared/types/extensions';
interface InstallButtonProps {
state: ExtensionOperationState;
isInstalled: boolean;
onInstall: () => void;
onUninstall: () => void;
disabled?: boolean;
size?: 'sm' | 'default';
}
export function InstallButton({
state,
isInstalled,
onInstall,
onUninstall,
disabled,
size = 'sm',
}: InstallButtonProps) {
if (state === 'pending') {
return (
<Button size={size} variant="outline" disabled>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="ml-1.5">{isInstalled ? 'Removing...' : 'Installing...'}</span>
</Button>
);
}
if (state === 'success') {
return (
<Button size={size} variant="outline" disabled className="text-green-400">
<Check className="h-3.5 w-3.5" />
<span className="ml-1.5">Done</span>
</Button>
);
}
if (state === 'error') {
return (
<Button
size={size}
variant="outline"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={(e) => {
e.stopPropagation();
(isInstalled ? onUninstall : onInstall)();
}}
disabled={disabled}
>
<span>Retry</span>
</Button>
);
}
// idle
if (isInstalled) {
return (
<Button
size={size}
variant="outline"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={(e) => {
e.stopPropagation();
onUninstall();
}}
disabled={disabled}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="ml-1.5">Uninstall</span>
</Button>
);
}
return (
<Button
size={size}
variant="default"
onClick={(e) => {
e.stopPropagation();
onInstall();
}}
disabled={disabled}
>
Install
</Button>
);
}

View file

@ -0,0 +1,22 @@
/**
* InstallCountBadge formatted download count with icon.
*/
import { Download } from 'lucide-react';
import { formatInstallCount } from '@shared/utils/extensionNormalizers';
interface InstallCountBadgeProps {
count: number;
}
export const InstallCountBadge = ({ count }: InstallCountBadgeProps): React.JSX.Element | null => {
if (count <= 0) return null;
return (
<span className="inline-flex items-center gap-1 text-xs text-text-muted">
<Download className="size-3" />
{formatInstallCount(count)}
</span>
);
};

View file

@ -0,0 +1,70 @@
/**
* SearchInput debounced search input with clear button.
*/
import { useEffect, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { Search, X } from 'lucide-react';
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
debounceMs?: number;
}
export const SearchInput = ({
value,
onChange,
placeholder = 'Search...',
debounceMs,
}: SearchInputProps): React.JSX.Element => {
const [localValue, setLocalValue] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = (next: string) => {
setLocalValue(next);
if (debounceMs && debounceMs > 0) {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onChange(next), debounceMs);
} else {
onChange(next);
}
};
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-text-muted" />
<Input
type="text"
value={localValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
className="pl-9 pr-8"
/>
{localValue && (
<Button
variant="ghost"
size="icon"
onClick={() => handleChange('')}
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
>
<X className="size-3.5" />
</Button>
)}
</div>
);
};

View file

@ -0,0 +1,31 @@
/**
* SourceBadge displays the source of a catalog item.
*/
import { Badge } from '@renderer/components/ui/badge';
interface SourceBadgeProps {
source: 'official' | 'glama' | string;
}
export const SourceBadge = ({ source }: SourceBadgeProps): React.JSX.Element => {
if (source === 'official') {
return (
<Badge className="border-blue-500/30 bg-blue-500/10 text-blue-400" variant="outline">
Official
</Badge>
);
}
if (source === 'glama') {
return (
<Badge className="border-zinc-500/30 bg-zinc-500/10 text-zinc-400" variant="outline">
Glama
</Badge>
);
}
return (
<Badge className="border-orange-500/30 bg-orange-500/10 text-orange-400" variant="outline">
Community
</Badge>
);
};

View file

@ -0,0 +1,118 @@
/**
* McpServerCard grid card for a single MCP server in the catalog.
* Shows server icon from registry when available.
*/
import { useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { useStore } from '@renderer/store';
import { Lock, Server, Wrench } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import type { McpCatalogItem } from '@shared/types/extensions';
interface McpServerCardProps {
server: McpCatalogItem;
isInstalled: boolean;
onClick: (serverId: string) => void;
}
export const McpServerCard = ({
server,
isInstalled,
onClick,
}: McpServerCardProps): React.JSX.Element => {
const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle');
const installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
const canAutoInstall = !!server.installSpec;
const [imgError, setImgError] = useState(false);
return (
<button
onClick={() => onClick(server.id)}
className={`flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
}`}
>
{/* Header: icon + name + source */}
<div className="flex items-start gap-2.5">
{/* Server icon or fallback */}
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
{server.iconUrl && !imgError ? (
<img
src={server.iconUrl}
alt=""
className="size-7 rounded object-contain"
onError={() => setImgError(true)}
/>
) : (
<Server className="size-4 text-text-muted" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-sm font-semibold text-text">{server.name}</h3>
<div className="flex shrink-0 items-center gap-1.5">
{isInstalled && (
<Badge
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
<SourceBadge source={server.source} />
</div>
</div>
</div>
</div>
{/* Description */}
<p className="line-clamp-2 text-xs text-text-secondary">{server.description}</p>
{/* Footer indicators + install button */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
{server.tools.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-surface-raised px-1.5 py-0.5 ring-1 ring-border">
<Wrench className="size-3" />
{server.tools.length}
</span>
)}
{server.requiresAuth && (
<span className="inline-flex items-center gap-1 text-amber-400">
<Lock className="size-3" />
Auth
</span>
)}
{server.license && <span>{server.license}</span>}
</div>
{canAutoInstall && (
<div className="shrink-0">
<InstallButton
state={installProgress}
isInstalled={isInstalled}
onInstall={() =>
installMcpServer({
registryId: server.id,
serverName: server.name.toLowerCase().replaceAll(/\s+/g, '-'),
scope: 'user',
envValues: {},
headers: [],
})
}
onUninstall={() =>
uninstallMcpServer(server.id, server.name.toLowerCase().replaceAll(/\s+/g, '-'))
}
size="sm"
/>
</div>
)}
</div>
</button>
);
};

View file

@ -0,0 +1,361 @@
/**
* McpServerDetailDialog full detail view for a single MCP server with install controls.
* Uses Radix UI Kit for all form elements.
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { api } from '@renderer/api';
import { ExternalLink, Lock, Plus, Server, Trash2, Wrench } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import type { McpCatalogItem, McpHeaderDef } from '@shared/types/extensions';
interface McpServerDetailDialogProps {
server: McpCatalogItem | null;
isInstalled: boolean;
open: boolean;
onClose: () => void;
}
type Scope = 'local' | 'user' | 'project';
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
export const McpServerDetailDialog = ({
server,
isInstalled,
open,
onClose,
}: McpServerDetailDialogProps): React.JSX.Element => {
const installProgress = useStore(
(s) => (server ? s.mcpInstallProgress[server.id] : undefined) ?? 'idle'
);
const installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
const [scope, setScope] = useState<Scope>('user');
const [serverName, setServerName] = useState('');
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
const [imgError, setImgError] = useState(false);
// Initialize form when server changes
const [lastServerId, setLastServerId] = useState<string | null>(null);
if (server && server.id !== lastServerId) {
setLastServerId(server.id);
setServerName(server.name.toLowerCase().replaceAll(/\s+/g, '-'));
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
setHeaders([]);
setImgError(false);
}
if (!server) return <></>;
const canAutoInstall = !!server.installSpec;
const isHttp = server.installSpec?.type === 'http';
const handleInstall = () => {
installMcpServer({
registryId: server.id,
serverName,
scope,
envValues,
headers,
});
};
const handleUninstall = () => {
uninstallMcpServer(server.id, serverName, scope);
};
const addHeader = () => {
setHeaders((prev) => [...prev, { key: '', value: '' }]);
};
const removeHeader = (index: number) => {
setHeaders((prev) => prev.filter((_, i) => i !== index));
};
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h)));
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<div className="flex items-start gap-3">
{/* Server icon */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
{server.iconUrl && !imgError ? (
<img
src={server.iconUrl}
alt=""
className="size-8 rounded object-contain"
onError={() => setImgError(true)}
/>
) : (
<Server className="size-5 text-text-muted" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<DialogTitle className="truncate">{server.name}</DialogTitle>
<DialogDescription className="mt-1">{server.description}</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{isInstalled && (
<Badge
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
<SourceBadge source={server.source} />
</div>
</div>
</div>
</div>
</DialogHeader>
{/* Metadata grid */}
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<span className="text-text-muted">Source</span>
<p className="capitalize text-text">{server.source}</p>
</div>
{server.version && (
<div>
<span className="text-text-muted">Version</span>
<p className="text-text">{server.version}</p>
</div>
)}
{server.license && (
<div>
<span className="text-text-muted">License</span>
<p className="text-text">{server.license}</p>
</div>
)}
<div>
<span className="text-text-muted">Install Type</span>
<p className="text-text">
{server.installSpec
? server.installSpec.type === 'stdio'
? `npm: ${server.installSpec.npmPackage}`
: `HTTP: ${server.installSpec.transportType}`
: 'Manual setup required'}
</p>
</div>
</div>
{/* Auth indicator */}
{server.requiresAuth && (
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-sm text-amber-400">
<Lock className="size-4" />
This server requires authentication
</div>
)}
{/* Install form */}
{canAutoInstall && (
<div className="space-y-3 rounded-md border border-border bg-surface-raised p-4">
<h4 className="text-sm font-medium text-text">
{isInstalled ? 'Manage Installation' : 'Install Server'}
</h4>
{/* Server name */}
<div className="space-y-1.5">
<Label htmlFor="server-name" className="text-xs">
Server Name
</Label>
<Input
id="server-name"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
placeholder="my-server"
className="h-8 text-sm"
/>
</div>
{/* Scope */}
<div className="space-y-1.5">
<Label className="text-xs">Scope</Label>
<Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Environment variables */}
{server.envVars.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Environment Variables</Label>
<div className="space-y-2">
{server.envVars.map((env) => (
<div key={env.name} className="flex items-center gap-2">
<code className="w-40 shrink-0 truncate text-xs text-blue-400">
{env.name}
</code>
<Input
type={env.isSecret ? 'password' : 'text'}
value={envValues[env.name] ?? ''}
onChange={(e) =>
setEnvValues((prev) => ({ ...prev, [env.name]: e.target.value }))
}
className="h-7 flex-1 text-xs"
placeholder={env.description ?? env.name}
/>
</div>
))}
</div>
</div>
)}
{/* Headers (for HTTP/SSE servers) */}
{isHttp && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Headers</Label>
<Button
variant="ghost"
size="sm"
onClick={addHeader}
className="h-6 px-1.5 text-xs"
>
<Plus className="mr-1 size-3" />
Add
</Button>
</div>
{headers.length > 0 && (
<div className="space-y-2">
{headers.map((header, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={header.key}
onChange={(e) => updateHeader(index, 'key', e.target.value)}
className="h-7 w-32 text-xs"
placeholder="Header-Name"
/>
<Input
type={header.secret ? 'password' : 'text'}
value={header.value}
onChange={(e) => updateHeader(index, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
placeholder="value"
/>
<Button
variant="ghost"
size="icon"
className="size-7 text-red-400 hover:bg-red-500/10"
onClick={() => removeHeader(index)}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* Install/Uninstall button */}
<div className="flex justify-end pt-1">
<InstallButton
state={installProgress}
isInstalled={isInstalled}
onInstall={handleInstall}
onUninstall={handleUninstall}
disabled={!serverName.trim()}
size="default"
/>
</div>
</div>
)}
{!canAutoInstall && (
<div className="rounded-md border border-border bg-surface-raised px-4 py-3 text-sm text-text-muted">
This server requires manual setup. Check the repository for installation instructions.
</div>
)}
{/* Tools */}
{server.tools.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-medium text-text">
<Wrench className="size-4" />
Tools ({server.tools.length})
</h4>
<div className="max-h-48 space-y-1 overflow-y-auto">
{server.tools.map((tool) => (
<div key={tool.name} className="rounded-md bg-surface-raised p-2 text-xs">
<code className="font-mono text-text">{tool.name}</code>
{tool.description && <p className="mt-0.5 text-text-muted">{tool.description}</p>}
</div>
))}
</div>
</div>
)}
{/* Links */}
<div className="flex items-center gap-4">
{server.repositoryUrl && (
<Button
variant="link"
className="h-auto p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(server.repositoryUrl!)}
>
<ExternalLink className="mr-1 size-3.5" />
Repository
</Button>
)}
{server.glamaUrl && (
<Button
variant="link"
className="h-auto p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(server.glamaUrl!)}
>
<ExternalLink className="mr-1 size-3.5" />
Glama
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,269 @@
/**
* McpServersPanel search and browse the MCP server catalog.
*/
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { AlertTriangle, Search, Server } from 'lucide-react';
import { SearchInput } from '../common/SearchInput';
import { McpServerCard } from './McpServerCard';
import { McpServerDetailDialog } from './McpServerDetailDialog';
import type { McpCatalogItem } from '@shared/types/extensions';
type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc';
const MCP_SORT_OPTIONS: { value: McpSortValue; label: string }[] = [
{ value: 'name-asc', label: 'Name A→Z' },
{ value: 'name-desc', label: 'Name Z→A' },
{ value: 'tools-desc', label: 'Most tools' },
];
function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatalogItem[] {
return [...servers].sort((a, b) => {
switch (sort) {
case 'name-asc':
return a.name.localeCompare(b.name);
case 'name-desc':
return b.name.localeCompare(a.name);
case 'tools-desc':
return b.tools.length - a.tools.length;
default:
return 0;
}
});
}
interface McpServersPanelProps {
mcpSearchQuery: string;
mcpSearch: (query: string) => void;
mcpSearchResults: McpCatalogItem[];
mcpSearchLoading: boolean;
mcpSearchWarnings: string[];
selectedMcpServerId: string | null;
setSelectedMcpServerId: (id: string | null) => void;
}
export const McpServersPanel = ({
mcpSearchQuery,
mcpSearch,
mcpSearchResults,
mcpSearchLoading,
mcpSearchWarnings,
selectedMcpServerId,
setSelectedMcpServerId,
}: McpServersPanelProps): React.JSX.Element => {
const browseCatalog = useStore((s) => s.mcpBrowseCatalog);
const browseNextCursor = useStore((s) => s.mcpBrowseNextCursor);
const browseLoading = useStore((s) => s.mcpBrowseLoading);
const browseError = useStore((s) => s.mcpBrowseError);
const mcpBrowse = useStore((s) => s.mcpBrowse);
const installedServers = useStore((s) => s.mcpInstalledServers);
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false);
// Load initial browse data
useEffect(() => {
if (browseCatalog.length === 0 && !browseLoading) {
void mcpBrowse();
}
}, [browseCatalog.length, browseLoading, mcpBrowse]);
// Decide which list to show: search results or browse
const isSearching = mcpSearchQuery.trim().length > 0;
const rawServers = isSearching ? mcpSearchResults : browseCatalog;
const isLoading = isSearching ? mcpSearchLoading : browseLoading;
const warnings = isSearching ? mcpSearchWarnings : [];
// Installed lookup set
const installedNames = useMemo(
() => new Set(installedServers.map((s) => s.name)),
[installedServers]
);
// Sort + filter
const displayServers = useMemo(() => {
let result = rawServers;
if (mcpInstalledOnly) {
result = result.filter((s) => installedNames.has(s.name));
}
return sortMcpServers(result, mcpSort);
}, [rawServers, mcpSort, mcpInstalledOnly, installedNames]);
// Find selected server (search in both lists to avoid losing selection during search toggle)
const selectedServer = useMemo(() => {
if (!selectedMcpServerId) return null;
return (
displayServers.find((s) => s.id === selectedMcpServerId) ??
browseCatalog.find((s) => s.id === selectedMcpServerId) ??
mcpSearchResults.find((s) => s.id === selectedMcpServerId) ??
null
);
}, [displayServers, browseCatalog, mcpSearchResults, selectedMcpServerId]);
return (
<div className="flex flex-col gap-4">
{/* Search + Sort + Installed only row */}
<div className="flex items-center gap-3">
<div className="flex-1">
<SearchInput
value={mcpSearchQuery}
onChange={mcpSearch}
placeholder="Search MCP servers..."
/>
</div>
<Select value={mcpSort} onValueChange={(v) => setMcpSort(v as McpSortValue)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MCP_SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Checkbox
id="mcp-installed-only"
checked={mcpInstalledOnly}
onCheckedChange={() => setMcpInstalledOnly(!mcpInstalledOnly)}
/>
<Label
htmlFor="mcp-installed-only"
className="whitespace-nowrap text-xs text-text-secondary"
>
Installed only
</Label>
</div>
</div>
{/* Warnings */}
{warnings.length > 0 && (
<div className="flex flex-col gap-1">
{warnings.map((w, i) => (
<div
key={i}
className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-400"
>
<AlertTriangle className="size-3.5 shrink-0" />
{w}
</div>
))}
</div>
)}
{/* Skeleton loading */}
{isLoading && displayServers.length === 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="skeleton-card flex flex-col gap-2 rounded-lg border border-border p-4"
style={{ animationDelay: `${i * 80}ms` }}
>
<div className="flex items-start gap-2.5">
<div className="size-9 rounded-lg bg-surface-raised" />
<div className="flex-1 space-y-1.5">
<div className="h-4 w-32 rounded bg-surface-raised" />
<div className="h-3 w-16 rounded-full bg-surface-raised" />
</div>
</div>
<div className="space-y-1.5">
<div className="h-3 w-full rounded bg-surface-raised" />
<div className="h-3 w-2/3 rounded bg-surface-raised" />
</div>
<div className="flex items-center justify-between">
<div className="h-5 w-12 rounded-full bg-surface-raised" />
<div className="h-7 w-16 rounded bg-surface-raised" />
</div>
</div>
))}
</div>
)}
{browseError && !isSearching && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
{browseError}
</div>
)}
{/* Empty state */}
{!isLoading && displayServers.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
<div className="flex size-10 items-center justify-center rounded-lg border border-border bg-surface-raised">
{isSearching || mcpInstalledOnly ? (
<Search className="size-5 text-text-muted" />
) : (
<Server className="size-5 text-text-muted" />
)}
</div>
<p className="text-sm text-text-secondary">
{isSearching
? 'No servers found'
: mcpInstalledOnly
? 'No installed servers'
: 'No MCP servers available'}
</p>
<p className="text-xs text-text-muted">
{isSearching
? 'Try a different search term'
: mcpInstalledOnly
? 'Install servers from the catalog to see them here'
: 'Check back later for new servers'}
</p>
</div>
)}
{displayServers.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{displayServers.map((server) => (
<McpServerCard
key={server.id}
server={server}
isInstalled={installedNames.has(server.name)}
onClick={setSelectedMcpServerId}
/>
))}
</div>
)}
{/* Load more for browse */}
{!isSearching && browseNextCursor && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
disabled={browseLoading}
onClick={() => void mcpBrowse(browseNextCursor)}
>
Load more
</Button>
</div>
)}
{/* Detail dialog */}
<McpServerDetailDialog
server={selectedServer}
isInstalled={selectedServer ? installedNames.has(selectedServer.name) : false}
open={selectedMcpServerId !== null}
onClose={() => setSelectedMcpServerId(null)}
/>
</div>
);
};

View file

@ -0,0 +1,61 @@
/**
* CapabilityChips filter chips for plugin capability types.
*/
import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button';
import { getCapabilityLabel, inferCapabilities } from '@shared/utils/extensionNormalizers';
import type { EnrichedPlugin, PluginCapability } from '@shared/types/extensions';
const ALL_CAPABILITIES: PluginCapability[] = ['lsp', 'mcp', 'agent', 'command', 'hook', 'skill'];
interface CapabilityChipsProps {
plugins: EnrichedPlugin[];
selected: PluginCapability[];
onToggle: (capability: PluginCapability) => void;
}
export const CapabilityChips = ({
plugins,
selected,
onToggle,
}: CapabilityChipsProps): React.JSX.Element => {
const capabilityCounts = useMemo(() => {
const counts = new Map<PluginCapability, number>();
for (const p of plugins) {
const caps = inferCapabilities(p);
for (const cap of caps) {
counts.set(cap, (counts.get(cap) ?? 0) + 1);
}
}
return counts;
}, [plugins]);
return (
<div className="flex flex-wrap gap-1.5">
{ALL_CAPABILITIES.map((cap) => {
const count = capabilityCounts.get(cap) ?? 0;
if (count === 0) return null;
const isActive = selected.includes(cap);
return (
<Button
key={cap}
variant="ghost"
size="sm"
onClick={() => onToggle(cap)}
className={`h-7 rounded-full px-2.5 text-xs font-medium ${
isActive
? 'bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/40 hover:bg-purple-500/30'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{getCapabilityLabel(cap)}
<span className="ml-1 text-text-muted">({count})</span>
</Button>
);
})}
</div>
);
};

View file

@ -0,0 +1,58 @@
/**
* CategoryChips horizontal filter chips for plugin categories.
*/
import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button';
import { normalizeCategory } from '@shared/utils/extensionNormalizers';
import type { EnrichedPlugin } from '@shared/types/extensions';
interface CategoryChipsProps {
plugins: EnrichedPlugin[];
selected: string[];
onToggle: (category: string) => void;
}
export const CategoryChips = ({
plugins,
selected,
onToggle,
}: CategoryChipsProps): React.JSX.Element => {
const categoryCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const p of plugins) {
const cat = normalizeCategory(p.category);
counts.set(cat, (counts.get(cat) ?? 0) + 1);
}
// Sort by count descending
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
}, [plugins]);
if (categoryCounts.length === 0) return <></>;
return (
<div className="flex flex-wrap gap-1.5">
{categoryCounts.map(([category, count]) => {
const isActive = selected.includes(category);
return (
<Button
key={category}
variant="ghost"
size="sm"
onClick={() => onToggle(category)}
className={`h-7 rounded-full px-2.5 text-xs font-medium ${
isActive
? 'bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/40 hover:bg-blue-500/30'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{category}
<span className="ml-1 text-text-muted">({count})</span>
</Button>
);
})}
</div>
);
};

View file

@ -0,0 +1,89 @@
/**
* PluginCard grid card for a single plugin in the catalog.
*/
import { Badge } from '@renderer/components/ui/badge';
import { useStore } from '@renderer/store';
import {
getCapabilityLabel,
inferCapabilities,
normalizeCategory,
} from '@shared/utils/extensionNormalizers';
import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge';
import type { EnrichedPlugin } from '@shared/types/extensions';
interface PluginCardProps {
plugin: EnrichedPlugin;
onClick: (pluginId: string) => void;
}
export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Element => {
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle');
const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
return (
<button
onClick={() => onClick(plugin.pluginId)}
className={`flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
}`}
>
{/* Header: name + installed badge */}
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-text">{plugin.name}</h3>
{plugin.isInstalled && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
</div>
{/* Description */}
<p className="line-clamp-2 text-xs text-text-secondary">{plugin.description}</p>
{/* Category + Capabilities */}
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="secondary" className="text-xs">
{category}
</Badge>
{capabilities.map((cap) => (
<Badge
key={cap}
variant="outline"
className="border-purple-500/30 bg-purple-500/10 text-xs text-purple-400"
>
{getCapabilityLabel(cap)}
</Badge>
))}
</div>
{/* Footer: author, install count, install button */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs text-text-muted">
{plugin.author?.name ?? 'Unknown author'}
</span>
<InstallCountBadge count={plugin.installCount} />
</div>
<div className="shrink-0">
<InstallButton
state={installProgress}
isInstalled={plugin.isInstalled}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
onUninstall={() => uninstallPlugin(plugin.pluginId)}
size="sm"
/>
</div>
</div>
</button>
);
};

View file

@ -0,0 +1,188 @@
/**
* PluginDetailDialog full detail view for a single plugin with install controls.
*/
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { api } from '@renderer/api';
import {
getCapabilityLabel,
inferCapabilities,
normalizeCategory,
} from '@shared/utils/extensionNormalizers';
import { ExternalLink, Loader2 } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions';
interface PluginDetailDialogProps {
plugin: EnrichedPlugin | null;
open: boolean;
onClose: () => void;
}
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
];
export const PluginDetailDialog = ({
plugin,
open,
onClose,
}: PluginDetailDialogProps): React.JSX.Element => {
const fetchPluginReadme = useStore((s) => s.fetchPluginReadme);
const readmes = useStore((s) => s.pluginReadmes);
const readmeLoading = useStore((s) => s.pluginReadmeLoading);
const installProgress = useStore(
(s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle'
);
const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
const [scope, setScope] = useState<InstallScope>('user');
useEffect(() => {
if (plugin && open) {
fetchPluginReadme(plugin.pluginId);
}
}, [plugin, open, fetchPluginReadme]);
if (!plugin) return <></>;
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const readme = readmes[plugin.pluginId];
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<DialogTitle className="truncate">{plugin.name}</DialogTitle>
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
</div>
{plugin.isInstalled && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
</div>
</DialogHeader>
{/* Metadata grid */}
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<span className="text-text-muted">Author</span>
<p className="text-text">{plugin.author?.name ?? 'Unknown'}</p>
</div>
<div>
<span className="text-text-muted">Category</span>
<p className="capitalize text-text">{category}</p>
</div>
<div>
<span className="text-text-muted">Capabilities</span>
<div className="mt-0.5 flex flex-wrap gap-1">
{capabilities.map((cap) => (
<Badge
key={cap}
variant="outline"
className="border-purple-500/30 bg-purple-500/10 text-purple-400"
>
{getCapabilityLabel(cap)}
</Badge>
))}
</div>
</div>
<div>
<span className="text-text-muted">Installs</span>
<div className="mt-0.5">
<InstallCountBadge count={plugin.installCount} />
</div>
</div>
</div>
{/* Install controls */}
<div className="flex items-center gap-3 rounded-md border border-border bg-surface-raised px-4 py-3">
<div className="flex flex-1 items-center gap-2">
<Label className="text-xs text-text-muted">Scope:</Label>
<Select value={scope} onValueChange={(v) => setScope(v as InstallScope)}>
<SelectTrigger className="h-7 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<InstallButton
state={installProgress}
isInstalled={plugin.isInstalled}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })}
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
size="default"
/>
</div>
{/* Homepage link */}
{plugin.homepage && (
<Button
variant="link"
className="h-auto justify-start p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(plugin.homepage!)}
>
<ExternalLink className="mr-1 size-3.5" />
Homepage
</Button>
)}
{/* README */}
<div className="mt-2 max-h-80 overflow-y-auto rounded-md border border-border bg-surface-raised p-4">
{isReadmeLoading && (
<div className="flex items-center gap-2 text-sm text-text-muted">
<Loader2 className="size-4 animate-spin" />
Loading README...
</div>
)}
{!isReadmeLoading && readme && (
<MarkdownViewer content={readme} bare maxHeight="max-h-none" />
)}
{!isReadmeLoading && !readme && (
<p className="text-sm text-text-muted">No README available.</p>
)}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,301 @@
/**
* PluginsPanel search, filter, sort and browse the plugin catalog.
*/
import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
import { Puzzle, Search } from 'lucide-react';
import { SearchInput } from '../common/SearchInput';
import { CapabilityChips } from './CapabilityChips';
import { CategoryChips } from './CategoryChips';
import { PluginCard } from './PluginCard';
import { PluginDetailDialog } from './PluginDetailDialog';
import type {
EnrichedPlugin,
PluginCapability,
PluginFilters,
PluginSortField,
} from '@shared/types/extensions';
interface PluginsPanelProps {
pluginFilters: PluginFilters;
pluginSort: { field: PluginSortField; order: 'asc' | 'desc' };
selectedPluginId: string | null;
updatePluginSearch: (search: string) => void;
toggleCategory: (category: string) => void;
toggleCapability: (capability: PluginCapability) => void;
toggleInstalledOnly: () => void;
setSelectedPluginId: (id: string | null) => void;
clearFilters: () => void;
hasActiveFilters: boolean;
setPluginSort: (sort: { field: PluginSortField; order: 'asc' | 'desc' }) => void;
}
const SORT_OPTIONS: { value: string; label: string }[] = [
{ value: 'popularity:desc', label: 'Popular' },
{ value: 'name:asc', label: 'Name A-Z' },
{ value: 'name:desc', label: 'Name Z-A' },
{ value: 'category:asc', label: 'Category' },
];
/** Pure function: filter + sort the catalog */
function selectFilteredPlugins(
catalog: EnrichedPlugin[],
filters: PluginFilters,
sort: { field: PluginSortField; order: 'asc' | 'desc' }
): EnrichedPlugin[] {
let result = catalog;
// Search
if (filters.search) {
const q = filters.search.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
p.pluginId.toLowerCase().includes(q)
);
}
// Categories
if (filters.categories.length > 0) {
result = result.filter((p) => filters.categories.includes(normalizeCategory(p.category)));
}
// Capabilities
if (filters.capabilities.length > 0) {
result = result.filter((p) => {
const caps = inferCapabilities(p);
return filters.capabilities.some((fc) => caps.includes(fc));
});
}
// Installed only
if (filters.installedOnly) {
result = result.filter((p) => p.isInstalled);
}
// Sort
const direction = sort.order === 'asc' ? 1 : -1;
result = [...result].sort((a, b) => {
switch (sort.field) {
case 'popularity':
return (b.installCount - a.installCount) * direction;
case 'name':
return a.name.localeCompare(b.name) * direction;
case 'category':
return a.category.localeCompare(b.category) * direction;
default:
return 0;
}
});
return result;
}
export const PluginsPanel = ({
pluginFilters,
pluginSort,
selectedPluginId,
updatePluginSearch,
toggleCategory,
toggleCapability,
toggleInstalledOnly,
setSelectedPluginId,
clearFilters,
hasActiveFilters,
setPluginSort,
}: PluginsPanelProps): React.JSX.Element => {
const catalog = useStore((s) => s.pluginCatalog);
const loading = useStore((s) => s.pluginCatalogLoading);
const error = useStore((s) => s.pluginCatalogError);
const filtered = useMemo(
() => selectFilteredPlugins(catalog, pluginFilters, pluginSort),
[catalog, pluginFilters, pluginSort]
);
const selectedPlugin = useMemo(
() =>
selectedPluginId ? (catalog.find((p) => p.pluginId === selectedPluginId) ?? null) : null,
[catalog, selectedPluginId]
);
const sortValue = `${pluginSort.field}:${pluginSort.order}`;
return (
<div className="flex flex-col gap-4">
{/* Search + Sort + Installed only row */}
<div className="flex items-center gap-3">
<div className="flex-1">
<SearchInput
value={pluginFilters.search}
onChange={updatePluginSearch}
placeholder="Search plugins..."
/>
</div>
<Select
value={sortValue}
onValueChange={(v) => {
const [field, order] = v.split(':') as [PluginSortField, 'asc' | 'desc'];
setPluginSort({ field, order });
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Checkbox
id="installed-only"
checked={pluginFilters.installedOnly}
onCheckedChange={toggleInstalledOnly}
/>
<Label htmlFor="installed-only" className="whitespace-nowrap text-xs text-text-secondary">
Installed only
</Label>
</div>
</div>
{/* Filters */}
<div className="bg-surface-raised/50 rounded-md border border-border p-3">
<div className="flex flex-col gap-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-muted">
Categories
</span>
{hasActiveFilters && (
<Button
variant="link"
size="sm"
onClick={clearFilters}
className="h-auto p-0 text-xs text-[var(--color-accent)]"
>
Clear all
</Button>
)}
</div>
<CategoryChips
plugins={catalog}
selected={pluginFilters.categories}
onToggle={toggleCategory}
/>
<div className="border-b border-border" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-muted">
Capabilities
</span>
<CapabilityChips
plugins={catalog}
selected={pluginFilters.capabilities}
onToggle={toggleCapability}
/>
</div>
</div>
{/* Result count */}
{!loading && !error && filtered.length > 0 && (
<p className="text-xs text-text-muted">
{filtered.length} plugin{filtered.length !== 1 ? 's' : ''}
</p>
)}
{/* Content */}
{loading && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="skeleton-card flex flex-col gap-2 rounded-lg border border-border p-4"
style={{ animationDelay: `${i * 80}ms` }}
>
<div className="flex items-start justify-between gap-2">
<div className="h-4 w-32 rounded bg-surface-raised" />
<div className="h-5 w-16 rounded-full bg-surface-raised" />
</div>
<div className="space-y-1.5">
<div className="h-3 w-full rounded bg-surface-raised" />
<div className="h-3 w-3/4 rounded bg-surface-raised" />
</div>
<div className="flex gap-1.5">
<div className="h-5 w-14 rounded-full bg-surface-raised" />
<div className="h-5 w-12 rounded-full bg-surface-raised" />
</div>
<div className="flex items-center justify-between">
<div className="h-3 w-24 rounded bg-surface-raised" />
<div className="h-7 w-16 rounded bg-surface-raised" />
</div>
</div>
))}
</div>
)}
{error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
{error}
</div>
)}
{!loading && !error && filtered.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
<div className="flex size-10 items-center justify-center rounded-lg border border-border bg-surface-raised">
{hasActiveFilters ? (
<Search className="size-5 text-text-muted" />
) : (
<Puzzle className="size-5 text-text-muted" />
)}
</div>
<p className="text-sm text-text-secondary">
{hasActiveFilters ? 'No plugins match your filters' : 'No plugins available'}
</p>
<p className="text-xs text-text-muted">
{hasActiveFilters
? 'Try adjusting your search or filter criteria'
: 'Check back later for new plugins'}
</p>
{hasActiveFilters && (
<Button variant="outline" size="sm" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
)}
{!loading && !error && filtered.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((plugin) => (
<PluginCard key={plugin.pluginId} plugin={plugin} onClick={setSelectedPluginId} />
))}
</div>
)}
{/* Detail dialog */}
<PluginDetailDialog
plugin={selectedPlugin}
open={selectedPluginId !== null}
onClose={() => setSelectedPluginId(null)}
/>
</div>
);
};

View file

@ -6,6 +6,7 @@
import { TabUIProvider } from '@renderer/contexts/TabUIContext'; import { TabUIProvider } from '@renderer/contexts/TabUIContext';
import { DashboardView } from '../dashboard/DashboardView'; import { DashboardView } from '../dashboard/DashboardView';
import { ExtensionStoreView } from '../extensions/ExtensionStoreView';
import { NotificationsView } from '../notifications/NotificationsView'; import { NotificationsView } from '../notifications/NotificationsView';
import { SessionReportTab } from '../report/SessionReportTab'; import { SessionReportTab } from '../report/SessionReportTab';
import { SettingsView } from '../settings/SettingsView'; import { SettingsView } from '../settings/SettingsView';
@ -57,6 +58,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
</TabUIProvider> </TabUIProvider>
)} )}
{tab.type === 'report' && <SessionReportTab tab={tab} />} {tab.type === 'report' && <SessionReportTab tab={tab} />}
{tab.type === 'extensions' && (
<TabUIProvider tabId={tab.id}>
<ExtensionStoreView />
</TabUIProvider>
)}
</div> </div>
); );
})} })}

View file

@ -17,6 +17,7 @@ import {
FileText, FileText,
LayoutDashboard, LayoutDashboard,
Pin, Pin,
Puzzle,
Search, Search,
Settings, Settings,
Users, Users,
@ -48,6 +49,7 @@ const TAB_ICONS = {
teams: Users, teams: Users,
team: Users, team: Users,
report: Activity, report: Activity,
extensions: Puzzle,
} as const; } as const;
export const SortableTab = ({ export const SortableTab = ({

View file

@ -15,7 +15,7 @@ import { isElectronMode } from '@renderer/api';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils'; import { formatShortcut } from '@renderer/utils/stringUtils';
import { Bell, PanelLeft, Plus, RefreshCw, Settings, Users } from 'lucide-react'; import { Bell, PanelLeft, Plus, Puzzle, RefreshCw, Settings, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { MoreMenu } from './MoreMenu'; import { MoreMenu } from './MoreMenu';
@ -44,6 +44,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
unreadCount, unreadCount,
openNotificationsTab, openNotificationsTab,
openTeamsTab, openTeamsTab,
openExtensionsTab,
openSettingsTab, openSettingsTab,
sidebarCollapsed, sidebarCollapsed,
toggleSidebar, toggleSidebar,
@ -71,6 +72,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
unreadCount: s.unreadCount, unreadCount: s.unreadCount,
openNotificationsTab: s.openNotificationsTab, openNotificationsTab: s.openNotificationsTab,
openTeamsTab: s.openTeamsTab, openTeamsTab: s.openTeamsTab,
openExtensionsTab: s.openExtensionsTab,
openSettingsTab: s.openSettingsTab, openSettingsTab: s.openSettingsTab,
sidebarCollapsed: s.sidebarCollapsed, sidebarCollapsed: s.sidebarCollapsed,
toggleSidebar: s.toggleSidebar, toggleSidebar: s.toggleSidebar,
@ -104,6 +106,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
const [newTabHover, setNewTabHover] = useState(false); const [newTabHover, setNewTabHover] = useState(false);
const [notificationsHover, setNotificationsHover] = useState(false); const [notificationsHover, setNotificationsHover] = useState(false);
const [teamsHover, setTeamsHover] = useState(false); const [teamsHover, setTeamsHover] = useState(false);
const [extensionsHover, setExtensionsHover] = useState(false);
const [githubHover, setGithubHover] = useState(false); const [githubHover, setGithubHover] = useState(false);
const [settingsHover, setSettingsHover] = useState(false); const [settingsHover, setSettingsHover] = useState(false);
@ -416,6 +419,21 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
<Users className="size-4" /> <Users className="size-4" />
</button> </button>
{/* Extensions icon */}
<button
onClick={openExtensionsTab}
onMouseEnter={() => setExtensionsHover(true)}
onMouseLeave={() => setExtensionsHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: extensionsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: extensionsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Extensions"
>
<Puzzle className="size-4" />
</button>
{/* GitHub link */} {/* GitHub link */}
<button <button
onClick={() => onClick={() =>

View file

@ -0,0 +1,167 @@
/**
* Per-tab UI state hook for the Extension Store view.
* Each Extensions tab instance gets its own independent state.
* Global catalog caches are in extensionsSlice (Zustand).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import type {
McpCatalogItem,
McpSearchResult,
PluginCapability,
PluginFilters,
PluginSortField,
} from '@shared/types/extensions';
export type ExtensionsSubTab = 'plugins' | 'mcp-servers';
interface PluginSortState {
field: PluginSortField;
order: 'asc' | 'desc';
}
const DEFAULT_FILTERS: PluginFilters = {
search: '',
categories: [],
capabilities: [],
installedOnly: false,
};
export function useExtensionsTabState() {
// ── Sub-tab navigation ──
const [activeSubTab, setActiveSubTab] = useState<ExtensionsSubTab>('plugins');
// ── Plugin filters & sort ──
const [pluginFilters, setPluginFilters] = useState<PluginFilters>(DEFAULT_FILTERS);
const [pluginSort, setPluginSort] = useState<PluginSortState>({
field: 'popularity',
order: 'desc',
});
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
// ── MCP search (per-tab, calls API directly) ──
const [mcpSearchQuery, setMcpSearchQuery] = useState('');
const [mcpSearchResults, setMcpSearchResults] = useState<McpCatalogItem[]>([]);
const [mcpSearchLoading, setMcpSearchLoading] = useState(false);
const [mcpSearchWarnings, setMcpSearchWarnings] = useState<string[]>([]);
const [selectedMcpServerId, setSelectedMcpServerId] = useState<string | null>(null);
// ── Debounced MCP search ──
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
};
}, []);
const mcpSearch = useCallback((query: string) => {
setMcpSearchQuery(query);
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
if (!query.trim()) {
setMcpSearchResults([]);
setMcpSearchWarnings([]);
setMcpSearchLoading(false);
return;
}
setMcpSearchLoading(true);
searchTimerRef.current = setTimeout(() => {
if (!api.mcpRegistry) {
setMcpSearchLoading(false);
return;
}
void api.mcpRegistry.search(query).then(
(result: McpSearchResult) => {
setMcpSearchResults(result.servers);
setMcpSearchWarnings(result.warnings);
setMcpSearchLoading(false);
},
() => {
setMcpSearchLoading(false);
setMcpSearchWarnings(['Search failed']);
}
);
}, 300);
}, []);
// ── Plugin filter helpers ──
const updatePluginSearch = useCallback((search: string) => {
setPluginFilters((prev) => ({ ...prev, search }));
}, []);
const toggleCategory = useCallback((category: string) => {
setPluginFilters((prev) => ({
...prev,
categories: prev.categories.includes(category)
? prev.categories.filter((c) => c !== category)
: [...prev.categories, category],
}));
}, []);
const toggleCapability = useCallback((capability: PluginCapability) => {
setPluginFilters((prev) => ({
...prev,
capabilities: prev.capabilities.includes(capability)
? prev.capabilities.filter((c) => c !== capability)
: [...prev.capabilities, capability],
}));
}, []);
const toggleInstalledOnly = useCallback(() => {
setPluginFilters((prev) => ({ ...prev, installedOnly: !prev.installedOnly }));
}, []);
const clearFilters = useCallback(() => {
setPluginFilters(DEFAULT_FILTERS);
}, []);
const hasActiveFilters = useMemo(
() =>
pluginFilters.search !== '' ||
pluginFilters.categories.length > 0 ||
pluginFilters.capabilities.length > 0 ||
pluginFilters.installedOnly,
[pluginFilters]
);
return {
// Sub-tab
activeSubTab,
setActiveSubTab,
// Plugins
pluginFilters,
pluginSort,
setPluginSort,
selectedPluginId,
setSelectedPluginId,
updatePluginSearch,
toggleCategory,
toggleCapability,
toggleInstalledOnly,
clearFilters,
hasActiveFilters,
// MCP
mcpSearchQuery,
mcpSearch,
mcpSearchResults,
mcpSearchLoading,
mcpSearchWarnings,
selectedMcpServerId,
setSelectedMcpServerId,
};
}

View file

@ -13,6 +13,7 @@ import { createConnectionSlice } from './slices/connectionSlice';
import { createContextSlice } from './slices/contextSlice'; import { createContextSlice } from './slices/contextSlice';
import { createConversationSlice } from './slices/conversationSlice'; import { createConversationSlice } from './slices/conversationSlice';
import { createEditorSlice } from './slices/editorSlice'; import { createEditorSlice } from './slices/editorSlice';
import { createExtensionsSlice } from './slices/extensionsSlice';
import { createNotificationSlice } from './slices/notificationSlice'; import { createNotificationSlice } from './slices/notificationSlice';
import { createPaneSlice } from './slices/paneSlice'; import { createPaneSlice } from './slices/paneSlice';
import { createProjectSlice } from './slices/projectSlice'; import { createProjectSlice } from './slices/projectSlice';
@ -61,6 +62,7 @@ export const useStore = create<AppState>()((...args) => ({
...createChangeReviewSlice(...args), ...createChangeReviewSlice(...args),
...createCliInstallerSlice(...args), ...createCliInstallerSlice(...args),
...createEditorSlice(...args), ...createEditorSlice(...args),
...createExtensionsSlice(...args),
})); }));
// ============================================================================= // =============================================================================

View file

@ -0,0 +1,356 @@
/**
* Extensions slice global catalog caches shared across all Extensions tabs.
* Per-tab UI state lives in useExtensionsTabState() hook, NOT here.
*/
import { api } from '@renderer/api';
import type { AppState } from '../types';
import type {
EnrichedPlugin,
ExtensionOperationState,
InstallScope,
InstalledMcpEntry,
McpCatalogItem,
McpInstallRequest,
PluginInstallRequest,
} from '@shared/types/extensions';
import type { StateCreator } from 'zustand';
// =============================================================================
// Slice Interface
// =============================================================================
export interface ExtensionsSlice {
// ── Plugin catalog cache ──
pluginCatalog: EnrichedPlugin[];
pluginCatalogLoading: boolean;
pluginCatalogError: string | null;
pluginCatalogProjectPath: string | null;
pluginReadmes: Record<string, string | null>;
pluginReadmeLoading: Record<string, boolean>;
// ── MCP catalog cache ──
mcpBrowseCatalog: McpCatalogItem[];
mcpBrowseNextCursor?: string;
mcpBrowseLoading: boolean;
mcpBrowseError: string | null;
mcpInstalledServers: InstalledMcpEntry[];
mcpInstalledProjectPath: string | null;
// ── Install progress ──
pluginInstallProgress: Record<string, ExtensionOperationState>;
mcpInstallProgress: Record<string, ExtensionOperationState>;
// ── Read actions ──
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
fetchPluginReadme: (pluginId: string) => void;
mcpBrowse: (cursor?: string) => Promise<void>;
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
// ── Mutation actions ──
installPlugin: (request: PluginInstallRequest) => Promise<void>;
uninstallPlugin: (pluginId: string, scope?: InstallScope, projectPath?: string) => Promise<void>;
installMcpServer: (request: McpInstallRequest) => Promise<void>;
uninstallMcpServer: (
registryId: string,
name: string,
scope?: string,
projectPath?: string
) => Promise<void>;
// ── Tab opener ──
openExtensionsTab: () => void;
}
// =============================================================================
// Slice Creator
// =============================================================================
let pluginFetchInFlight: Promise<void> | null = null;
/** Duration to show "success" state before returning to idle */
const SUCCESS_DISPLAY_MS = 2_000;
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set,
get
) => ({
// ── Initial state ──
pluginCatalog: [],
pluginCatalogLoading: false,
pluginCatalogError: null,
pluginCatalogProjectPath: null,
pluginReadmes: {},
pluginReadmeLoading: {},
mcpBrowseCatalog: [],
mcpBrowseNextCursor: undefined,
mcpBrowseLoading: false,
mcpBrowseError: null,
mcpInstalledServers: [],
mcpInstalledProjectPath: null,
pluginInstallProgress: {},
mcpInstallProgress: {},
// ── Plugin catalog fetch ──
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
if (!api.plugins) return;
// Dedup concurrent requests
if (pluginFetchInFlight && !forceRefresh) {
await pluginFetchInFlight;
return;
}
set({ pluginCatalogLoading: true, pluginCatalogError: null });
const promise = (async () => {
try {
const result = await api.plugins!.getAll(projectPath, forceRefresh);
set({
pluginCatalog: result,
pluginCatalogLoading: false,
pluginCatalogProjectPath: projectPath ?? null,
});
} catch (err) {
set({
pluginCatalogLoading: false,
pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins',
});
} finally {
pluginFetchInFlight = null;
}
})();
pluginFetchInFlight = promise;
await promise;
},
// ── Plugin README fetch ──
fetchPluginReadme: (pluginId: string) => {
if (!api.plugins) return;
const state = get();
if (pluginId in state.pluginReadmes || state.pluginReadmeLoading[pluginId]) return;
set((prev) => ({
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true },
}));
void api.plugins.getReadme(pluginId).then(
(readme) => {
set((prev) => ({
pluginReadmes: { ...prev.pluginReadmes, [pluginId]: readme },
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: false },
}));
},
() => {
set((prev) => ({
pluginReadmes: { ...prev.pluginReadmes, [pluginId]: null },
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: false },
}));
}
);
},
// ── MCP browse ──
mcpBrowse: async (cursor?: string) => {
if (!api.mcpRegistry) return;
set({ mcpBrowseLoading: true, mcpBrowseError: null });
try {
const result = await api.mcpRegistry.browse(cursor);
set((prev) => ({
mcpBrowseCatalog: cursor ? [...prev.mcpBrowseCatalog, ...result.servers] : result.servers,
mcpBrowseNextCursor: result.nextCursor,
mcpBrowseLoading: false,
}));
} catch (err) {
set({
mcpBrowseLoading: false,
mcpBrowseError: err instanceof Error ? err.message : 'Failed to browse MCP servers',
});
}
},
// ── MCP installed fetch ──
mcpFetchInstalled: async (projectPath?: string) => {
if (!api.mcpRegistry) return;
try {
const installed = await api.mcpRegistry.getInstalled(projectPath);
set({
mcpInstalledServers: installed,
mcpInstalledProjectPath: projectPath ?? null,
});
} catch {
// Silently fail — installed state is supplementary
}
},
// ── Plugin install ──
installPlugin: async (request: PluginInstallRequest) => {
if (!api.plugins) return;
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
}));
try {
const result = await api.plugins.install(request);
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
}));
return;
}
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'success' },
}));
// Refresh catalog to pick up new installed state
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
// Return to idle after brief success display
setTimeout(() => {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
}));
}
},
// ── Plugin uninstall ──
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
if (!api.plugins) return;
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
}));
try {
const result = await api.plugins.uninstall(pluginId, scope, projectPath);
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
}));
return;
}
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'success' },
}));
// Refresh catalog
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
setTimeout(() => {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
}));
}
},
// ── MCP install ──
installMcpServer: async (request: McpInstallRequest) => {
if (!api.mcpRegistry) return;
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' },
}));
try {
const result = await api.mcpRegistry.install(request);
if (result.state === 'error') {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
}));
return;
}
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' },
}));
// Refresh installed list
void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined);
setTimeout(() => {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
}));
}
},
// ── MCP uninstall ──
uninstallMcpServer: async (
registryId: string,
name: string,
scope?: string,
projectPath?: string
) => {
if (!api.mcpRegistry) return;
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' },
}));
try {
const result = await api.mcpRegistry.uninstall(name, scope, projectPath);
if (result.state === 'error') {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
}));
return;
}
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' },
}));
void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined);
setTimeout(() => {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
}));
}
},
// ── Tab opener ──
openExtensionsTab: () => {
const state = get();
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions');
if (existingTab) {
state.setActiveTab(existingTab.id);
return;
}
state.openTab({
type: 'extensions',
label: 'Extensions',
});
},
});

View file

@ -10,6 +10,7 @@ import type { ConnectionSlice } from './slices/connectionSlice';
import type { ContextSlice } from './slices/contextSlice'; import type { ContextSlice } from './slices/contextSlice';
import type { ConversationSlice } from './slices/conversationSlice'; import type { ConversationSlice } from './slices/conversationSlice';
import type { EditorSlice } from './slices/editorSlice'; import type { EditorSlice } from './slices/editorSlice';
import type { ExtensionsSlice } from './slices/extensionsSlice';
import type { NotificationSlice } from './slices/notificationSlice'; import type { NotificationSlice } from './slices/notificationSlice';
import type { PaneSlice } from './slices/paneSlice'; import type { PaneSlice } from './slices/paneSlice';
import type { ProjectSlice } from './slices/projectSlice'; import type { ProjectSlice } from './slices/projectSlice';
@ -98,4 +99,5 @@ export type AppState = ProjectSlice &
UpdateSlice & UpdateSlice &
ChangeReviewSlice & ChangeReviewSlice &
CliInstallerSlice & CliInstallerSlice &
EditorSlice; EditorSlice &
ExtensionsSlice;

View file

@ -76,7 +76,15 @@ export interface Tab {
id: string; id: string;
/** Type of content displayed in this tab */ /** Type of content displayed in this tab */
type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'teams' | 'team' | 'report'; type:
| 'session'
| 'dashboard'
| 'notifications'
| 'settings'
| 'teams'
| 'team'
| 'report'
| 'extensions';
/** Session ID (required when type === 'session') */ /** Session ID (required when type === 'session') */
sessionId?: string; sessionId?: string;

View file

@ -10,6 +10,7 @@
import type { CliArgsValidationResult } from '../utils/cliArgsParser'; import type { CliArgsValidationResult } from '../utils/cliArgsParser';
import type { CliInstallerAPI } from './cliInstaller'; import type { CliInstallerAPI } from './cliInstaller';
import type { EditorAPI, ProjectAPI } from './editor'; import type { EditorAPI, ProjectAPI } from './editor';
import type { McpCatalogAPI, PluginCatalogAPI } from './extensions';
import type { import type {
AppConfig, AppConfig,
DetectedError, DetectedError,
@ -744,6 +745,12 @@ export interface ElectronAPI {
// Project Editor API (file browser + CodeMirror) // Project Editor API (file browser + CodeMirror)
editor: EditorAPI; editor: EditorAPI;
// Extension Store — Plugin Catalog API (Electron-only, optional)
plugins?: PluginCatalogAPI;
// Extension Store — MCP Registry API (Electron-only, optional)
mcpRegistry?: McpCatalogAPI;
} }
// ============================================================================= // =============================================================================

View file

@ -0,0 +1,35 @@
/**
* Extension Store API contracts exposed via preload bridge.
* Both APIs are OPTIONAL in ElectronAPI (Electron-only V1).
*/
import type { InstallScope, OperationResult } from './common';
import type { EnrichedPlugin, PluginInstallRequest } from './plugin';
import type { InstalledMcpEntry, McpCatalogItem, McpInstallRequest, McpSearchResult } from './mcp';
// ── Plugin API ─────────────────────────────────────────────────────────────
export interface PluginCatalogAPI {
getAll: (projectPath?: string, forceRefresh?: boolean) => Promise<EnrichedPlugin[]>;
getReadme: (pluginId: string) => Promise<string | null>;
install: (request: PluginInstallRequest) => Promise<OperationResult>;
uninstall: (
pluginId: string,
scope?: InstallScope,
projectPath?: string
) => Promise<OperationResult>;
}
// ── MCP API ────────────────────────────────────────────────────────────────
export interface McpCatalogAPI {
search: (query: string, limit?: number) => Promise<McpSearchResult>;
browse: (
cursor?: string,
limit?: number
) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>;
getById: (registryId: string) => Promise<McpCatalogItem | null>;
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
install: (request: McpInstallRequest) => Promise<OperationResult>;
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
}

View file

@ -0,0 +1,16 @@
/**
* Common types shared across plugin and MCP extension domains.
*/
/** Operation progress state for install/uninstall mutations */
export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error';
/** Installation scope — where the extension is installed */
export type InstallScope = 'local' | 'user' | 'project';
/** Result of a mutation operation */
export interface OperationResult<T = void> {
state: ExtensionOperationState;
data?: T;
error?: string;
}

View file

@ -0,0 +1,31 @@
/**
* Extension Store types barrel export.
*/
export type { ExtensionOperationState, InstallScope, OperationResult } from './common';
export type {
EnrichedPlugin,
InstalledPluginEntry,
PluginCapability,
PluginCatalogItem,
PluginFilters,
PluginInstallRequest,
PluginSortField,
} from './plugin';
export { inferCapabilities } from './plugin';
export type {
InstalledMcpEntry,
McpCatalogItem,
McpEnvVarDef,
McpHeaderDef,
McpHttpInstallSpec,
McpInstallRequest,
McpInstallSpec,
McpSearchResult,
McpStdioInstallSpec,
McpToolDef,
} from './mcp';
export type { McpCatalogAPI, PluginCatalogAPI } from './api';

View file

@ -0,0 +1,85 @@
/**
* MCP server domain types catalog items, install specs, installed state, headers.
*/
// ── Catalog item (normalized from Official Registry / Glama) ───────────────
export interface McpCatalogItem {
id: string; // Official: reverse-DNS (e.g. "io.github.upstash/context7"), Glama: "glama:<id>"
name: string; // display name
description: string;
repositoryUrl?: string;
version?: string;
source: 'official' | 'glama';
installSpec: McpInstallSpec | null; // null = can't auto-install (Glama-only)
envVars: McpEnvVarDef[];
license?: string;
tools: McpToolDef[];
glamaUrl?: string;
requiresAuth: boolean; // true if HTTP server has required headers
iconUrl?: string; // First icon URL from official registry (icons[0].src)
}
export interface McpToolDef {
name: string;
description: string;
}
// ── Install spec (derived from registry packages/remotes) ──────────────────
export type McpInstallSpec = McpStdioInstallSpec | McpHttpInstallSpec;
export interface McpStdioInstallSpec {
type: 'stdio';
npmPackage: string; // "@upstash/context7-mcp"
npmVersion?: string;
}
export interface McpHttpInstallSpec {
type: 'http';
url: string;
transportType: 'streamable-http' | 'sse' | 'http';
}
// ── Environment variables ──────────────────────────────────────────────────
export interface McpEnvVarDef {
name: string;
isSecret: boolean;
description?: string;
isRequired?: boolean; // from registry, but treat all as optional in UI
}
// ── HTTP headers (for auth/config of HTTP/SSE servers) ─────────────────────
export interface McpHeaderDef {
key: string;
value: string;
secret?: boolean; // true = mask in UI, don't log
}
// ── Installed state (from ~/.claude.json / .mcp.json) ──────────────────────
export interface InstalledMcpEntry {
name: string;
scope: 'local' | 'user' | 'project';
transport?: string;
}
// ── Install request (renderer → main, minimal trusted data) ────────────────
export interface McpInstallRequest {
registryId: string; // server ID from registry (NOT full catalog item)
serverName: string; // user-chosen name for `claude mcp add`
scope: 'local' | 'user' | 'project';
projectPath?: string; // required for 'project' scope
envValues: Record<string, string>;
headers: McpHeaderDef[]; // for HTTP/SSE servers (CLI --header flag)
}
// ── Search result wrapper ──────────────────────────────────────────────────
export interface McpSearchResult {
servers: McpCatalogItem[];
warnings: string[]; // e.g. "Official registry unavailable"
}

View file

@ -0,0 +1,84 @@
/**
* Plugin domain types catalog items, installed state, enriched plugins, filters.
*/
import type { InstallScope } from './common';
// ── Catalog item (read from marketplace.json) ──────────────────────────────
export interface PluginCatalogItem {
// Identity
pluginId: string; // canonical key = qualifiedName for V1 (<name>@<marketplace>)
marketplaceId: string; // = qualifiedName in V1
qualifiedName: string; // CLI install target, resolved by main
name: string; // display name only
// Metadata
description: string;
category: string; // open-ended string, derived from marketplace.json
author?: { name: string; email?: string };
version?: string;
homepage?: string;
tags?: string[]; // not present in current marketplace, future-proofing
// Capability flags (derived from marketplace.json plugin structure)
hasLspServers: boolean;
hasMcpServers: boolean;
hasAgents: boolean;
hasCommands: boolean;
hasHooks: boolean;
isExternal: boolean; // source is object with URL (not local path)
}
// ── Installed state ────────────────────────────────────────────────────────
export interface InstalledPluginEntry {
pluginId: string; // matches PluginCatalogItem.pluginId
scope: InstallScope;
version?: string;
installedAt?: string;
installPath?: string;
}
// ── Enriched (catalog + installed + counts, for renderer) ──────────────────
export interface EnrichedPlugin extends PluginCatalogItem {
installCount: number;
isInstalled: boolean;
installations: InstalledPluginEntry[];
}
// ── Capabilities ───────────────────────────────────────────────────────────
export type PluginCapability = 'lsp' | 'mcp' | 'agent' | 'command' | 'hook' | 'skill';
/** Derive display capabilities from flag fields */
export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] {
const caps: PluginCapability[] = [];
if (item.hasLspServers) caps.push('lsp');
if (item.hasMcpServers) caps.push('mcp');
if (item.hasAgents) caps.push('agent');
if (item.hasCommands) caps.push('command');
if (item.hasHooks) caps.push('hook');
if (caps.length === 0) caps.push('skill'); // fallback
return caps;
}
// ── Install request (renderer → main) ──────────────────────────────────────
export interface PluginInstallRequest {
pluginId: string; // canonical key — main resolves qualifiedName from catalog
scope: InstallScope;
projectPath?: string; // required for 'project' scope
}
// ── Filters (renderer-only concern) ────────────────────────────────────────
export interface PluginFilters {
search: string;
categories: string[];
capabilities: PluginCapability[];
installedOnly: boolean;
}
export type PluginSortField = 'popularity' | 'name' | 'category';

View file

@ -38,3 +38,6 @@ export type * from './terminal';
// Re-export Editor types // Re-export Editor types
export type * from './editor'; export type * from './editor';
// Re-export Extension Store types (inferCapabilities is re-exported from extensionNormalizers)
export type * from './extensions';

View file

@ -0,0 +1,91 @@
/**
* Pure-function normalizers for Extension Store data.
*/
import type { PluginCapability, PluginCatalogItem } from '@shared/types/extensions';
/**
* Normalize a repository URL for dedup comparison.
* Lowercases, strips `.git` suffix, strips trailing `/`.
*/
export function normalizeRepoUrl(url: string): string {
return url
.toLowerCase()
.replace(/\.git$/, '')
.replace(/\/+$/, '');
}
/**
* Derive UI-visible capability labels from plugin capability flags.
*/
export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] {
const caps: PluginCapability[] = [];
if (item.hasLspServers) caps.push('lsp');
if (item.hasMcpServers) caps.push('mcp');
if (item.hasAgents) caps.push('agent');
if (item.hasCommands) caps.push('command');
if (item.hasHooks) caps.push('hook');
if (caps.length === 0) caps.push('skill');
return caps;
}
const CAPABILITY_LABELS: Record<PluginCapability, string> = {
lsp: 'LSP',
mcp: 'MCP',
agent: 'Agent',
command: 'Command',
hook: 'Hook',
skill: 'Skill',
};
/**
* Get a human-readable label for the primary capability.
*/
export function getPrimaryCapabilityLabel(capabilities: PluginCapability[]): string {
if (capabilities.length === 0) return 'Skill';
return CAPABILITY_LABELS[capabilities[0]];
}
/**
* Get human-readable label for a capability.
*/
export function getCapabilityLabel(capability: PluginCapability): string {
return CAPABILITY_LABELS[capability];
}
/**
* Format large install counts for display.
* 277472 "277K", 1200000 "1.2M", 42 "42"
*/
export function formatInstallCount(count: number): string {
if (count >= 1_000_000) {
const millions = count / 1_000_000;
return millions >= 10
? `${Math.round(millions)}M`
: `${millions.toFixed(1).replace(/\.0$/, '')}M`;
}
if (count >= 1_000) {
const thousands = count / 1_000;
return thousands >= 10
? `${Math.round(thousands)}K`
: `${thousands.toFixed(1).replace(/\.0$/, '')}K`;
}
return String(count);
}
/**
* Normalize a category string for consistent comparison/display.
* Lowercases, trims, falls back to "other".
*/
export function normalizeCategory(raw: string | undefined): string {
if (!raw) return 'other';
const normalized = raw.trim().toLowerCase();
return normalized || 'other';
}
/**
* Build a pluginId (= qualifiedName) from marketplace plugin name + marketplace name.
*/
export function buildPluginId(pluginName: string, marketplaceName: string): string {
return `${pluginName}@${marketplaceName}`;
}

View file

@ -0,0 +1 @@
{"pageInfo":{"endCursor":"eyJjcmVhdGVkQXQiOjE3NzI4MjM3ODksImlkIjoiaXVweHFqMHBqNSJ9","hasNextPage":true,"hasPreviousPage":false,"startCursor":"eyJjcmVhdGVkQXQiOjE3NzI4Mjc1MTAsImlkIjoiaXUyN3ZmcmppMiJ9"},"servers":[{"attributes":[],"description":"Search and discover 3,500+ AI tools, MCP servers, and Claude Skills with community ratings. Find the best tools by category, compatibility, and real user reviews.","environmentVariablesJsonSchema":null,"id":"iu27vfrji2","name":"clelp-mcp-server","namespace":"oscarsterling","repository":{"url":"https://github.com/oscarsterling/clelp-mcp-server"},"slug":"clelp-mcp-server","spdxLicense":{"name":"MIT License","url":"https://spdx.org/licenses/MIT.json"},"tools":[],"url":"https://glama.ai/mcp/servers/iu27vfrji2"},{"attributes":[],"description":"MCP server that exposes unified AI-friendly tools over Messari's standardized lending subgraphs on The Graph.\n\nOne natural-language query → fan out across 40+ lending protocols on multiple chains → get back structured, comparable data.","environmentVariablesJsonSchema":null,"id":"iohguv6uiu","name":"graph-lending-mcp","namespace":"PaulieB14","repository":{"url":"https://github.com/PaulieB14/graph-lending-mcp"},"slug":"graph-lending-mcp","spdxLicense":{"name":"MIT License","url":"https://spdx.org/licenses/MIT.json"},"tools":[],"url":"https://glama.ai/mcp/servers/iohguv6uiu"},{"attributes":[],"description":"Integrates Charles Proxy with MCP clients to provide real-time and historical network traffic capture and structured analysis. It features a summary-first approach that filters noise and desensitizes data for efficient, low-token agent debugging and monitoring.","environmentVariablesJsonSchema":null,"id":"h3qrwl9jtl","name":"Charles MCP Server","namespace":"heizaheiza","repository":{"url":"https://github.com/heizaheiza/Charles-mcp"},"slug":"Charles-mcp","spdxLicense":{"name":"MIT License","url":"https://spdx.org/licenses/MIT.json"},"tools":[],"url":"https://glama.ai/mcp/servers/h3qrwl9jtl"},{"attributes":[],"description":"Enables interaction with Salesforce through REST, Bulk API, and SOQL for comprehensive CRM management across objects like Leads, Accounts, and Opportunities. It provides a structured interface for performing CRUD operations and executing complex queries via the Model Context Protocol.","environmentVariablesJsonSchema":null,"id":"abwvjupkx7","name":"Salesforce MCP Server","namespace":"shubhampwc2911","repository":{"url":"https://github.com/shubhampwc2911/mcp"},"slug":"mcp","spdxLicense":null,"tools":[],"url":"https://glama.ai/mcp/servers/abwvjupkx7"},{"attributes":[],"description":"An MCP server that enables AI assistants to perform web searches on Perplexity.ai using browser automation instead of an official API. It supports persistent authenticated sessions and returns search results along with cited sources directly to the client.","environmentVariablesJsonSchema":null,"id":"iupxqj0pj5","name":"perplexity-web-mcp","namespace":"quequiere","repository":{"url":"https://github.com/quequiere/perplexity-web-mcp"},"slug":"perplexity-web-mcp","spdxLicense":{"name":"MIT License","url":"https://spdx.org/licenses/MIT.json"},"tools":[],"url":"https://glama.ai/mcp/servers/iupxqj0pj5"}]}

View file

@ -0,0 +1 @@
{"pageInfo":{"endCursor":"eyJjcmVhdGVkQXQiOjE3NzI4OTU2NDgsImlkIjoiZW0waXNrNXlwYiJ9","hasNextPage":true,"hasPreviousPage":false,"startCursor":"eyJjcmVhdGVkQXQiOjE3NzI4OTcxMTEsImlkIjoib2FocXptcGZ1byJ9"},"servers":[{"attributes":[],"description":"Unified deployment dashboard MCP server for AI agents. 9 tools to manage services across Vercel, Render, Railway, and Fly.io — deploy status, logs, service listing, environment variables, rollback, and health checks from one endpoint. Free tier: 50 requests/IP/day.","environmentVariablesJsonSchema":null,"id":"oahqzmpfuo","name":"agent-deploy-dashbaord","namespace":"aparajithn","repository":{"url":"https://github.com/aparajithn/agent-deploy-dashboard-mcp"},"slug":"agent-deploy-dashbaord","spdxLicense":{"name":"MIT License","url":"https://spdx.org/licenses/MIT.json"},"tools":[],"url":"https://glama.ai/mcp/servers/oahqzmpfuo"},{"attributes":[],"description":"Enables AI agents to create and manage Kibana dashboards, Lens visualizations, and data views via the Kibana Saved Objects API. It allows for programmatically listing existing resources and assembling new visualizations into dashboards through natural language commands.","environmentVariablesJsonSchema":null,"id":"hc4ec6du1y","name":"Kibana Dashboard Builder","namespace":"brienhackney","repository":{"url":"https://github.com/brienhackney/kibana-mcp-server"},"slug":"kibana-mcp-server","spdxLicense":null,"tools":[],"url":"https://glama.ai/mcp/servers/hc4ec6du1y"},{"attributes":[],"description":"18 composite tools for structured Godot 4.x interaction: scenes, nodes, GDScript, shaders, animation, tilemap, physics, and more.","environmentVariablesJsonSchema":null,"id":"em0isk5ypb","name":"better-godot-mcp","namespace":"n24q02m","repository":{"url":"https://github.com/n24q02m/better-godot-mcp"},"slug":"better-godot-mcp","spdxLicense":{"name":"MIT License","url":"https://spdx.org/licenses/MIT.json"},"tools":[],"url":"https://glama.ai/mcp/servers/em0isk5ypb"}]}

View file

@ -0,0 +1,634 @@
{
"version": 1,
"fetchedAt": "2026-03-06T18:17:44.050Z",
"counts": [
{
"plugin": "frontend-design@claude-plugins-official",
"unique_installs": 277472
},
{
"plugin": "context7@claude-plugins-official",
"unique_installs": 150681
},
{
"plugin": "superpowers@claude-plugins-official",
"unique_installs": 143054
},
{
"plugin": "code-review@claude-plugins-official",
"unique_installs": 129039
},
{
"plugin": "github@claude-plugins-official",
"unique_installs": 111212
},
{
"plugin": "feature-dev@claude-plugins-official",
"unique_installs": 106567
},
{
"plugin": "code-simplifier@claude-plugins-official",
"unique_installs": 105663
},
{
"plugin": "ralph-loop@claude-plugins-official",
"unique_installs": 89731
},
{
"plugin": "playwright@claude-plugins-official",
"unique_installs": 88109
},
{
"plugin": "typescript-lsp@claude-plugins-official",
"unique_installs": 84177
},
{
"plugin": "commit-commands@claude-plugins-official",
"unique_installs": 69620
},
{
"plugin": "security-guidance@claude-plugins-official",
"unique_installs": 67619
},
{
"plugin": "claude-md-management@claude-plugins-official",
"unique_installs": 60565
},
{
"plugin": "serena@claude-plugins-official",
"unique_installs": 54940
},
{
"plugin": "figma@claude-plugins-official",
"unique_installs": 51302
},
{
"plugin": "pr-review-toolkit@claude-plugins-official",
"unique_installs": 47641
},
{
"plugin": "pyright-lsp@claude-plugins-official",
"unique_installs": 43016
},
{
"plugin": "supabase@claude-plugins-official",
"unique_installs": 39799
},
{
"plugin": "claude-code-setup@claude-plugins-official",
"unique_installs": 37369
},
{
"plugin": "atlassian@claude-plugins-official",
"unique_installs": 34307
},
{
"plugin": "agent-sdk-dev@claude-plugins-official",
"unique_installs": 34016
},
{
"plugin": "explanatory-output-style@claude-plugins-official",
"unique_installs": 28775
},
{
"plugin": "plugin-dev@claude-plugins-official",
"unique_installs": 28565
},
{
"plugin": "greptile@claude-plugins-official",
"unique_installs": 27358
},
{
"plugin": "ralph-wiggum@claude-plugins-official",
"unique_installs": 27212
},
{
"plugin": "Notion@claude-plugins-official",
"unique_installs": 24273
},
{
"plugin": "hookify@claude-plugins-official",
"unique_installs": 24055
},
{
"plugin": "vercel@claude-plugins-official",
"unique_installs": 21331
},
{
"plugin": "linear@claude-plugins-official",
"unique_installs": 20187
},
{
"plugin": "slack@claude-plugins-official",
"unique_installs": 19506
},
{
"plugin": "skill-creator@claude-plugins-official",
"unique_installs": 19066
},
{
"plugin": "learning-output-style@claude-plugins-official",
"unique_installs": 19025
},
{
"plugin": "playground@claude-plugins-official",
"unique_installs": 18651
},
{
"plugin": "gopls-lsp@claude-plugins-official",
"unique_installs": 16378
},
{
"plugin": "csharp-lsp@claude-plugins-official",
"unique_installs": 16124
},
{
"plugin": "sentry@claude-plugins-official",
"unique_installs": 15799
},
{
"plugin": "stripe@claude-plugins-official",
"unique_installs": 14609
},
{
"plugin": "gitlab@claude-plugins-official",
"unique_installs": 14574
},
{
"plugin": "rust-analyzer-lsp@claude-plugins-official",
"unique_installs": 14283
},
{
"plugin": "php-lsp@claude-plugins-official",
"unique_installs": 12588
},
{
"plugin": "jdtls-lsp@claude-plugins-official",
"unique_installs": 12443
},
{
"plugin": "huggingface-skills@claude-plugins-official",
"unique_installs": 11635
},
{
"plugin": "laravel-boost@claude-plugins-official",
"unique_installs": 11492
},
{
"plugin": "clangd-lsp@claude-plugins-official",
"unique_installs": 10973
},
{
"plugin": "firebase@claude-plugins-official",
"unique_installs": 10505
},
{
"plugin": "swift-lsp@claude-plugins-official",
"unique_installs": 10157
},
{
"plugin": "coderabbit@claude-plugins-official",
"unique_installs": 8761
},
{
"plugin": "kotlin-lsp@claude-plugins-official",
"unique_installs": 8111
},
{
"plugin": "firecrawl@claude-plugins-official",
"unique_installs": 6552
},
{
"plugin": "lua-lsp@claude-plugins-official",
"unique_installs": 6512
},
{
"plugin": "circleback@claude-plugins-official",
"unique_installs": 4934
},
{
"plugin": "asana@claude-plugins-official",
"unique_installs": 4636
},
{
"plugin": "pinecone@claude-plugins-official",
"unique_installs": 4323
},
{
"plugin": "posthog@claude-plugins-official",
"unique_installs": 4004
},
{
"plugin": "semgrep@claude-plugins-official",
"unique_installs": 3873
},
{
"plugin": "sonatype-guide@claude-plugins-official",
"unique_installs": 2894
},
{
"plugin": "claude-opus-4-5-migration@claude-plugins-official",
"unique_installs": 2714
},
{
"plugin": "qodo-skills@claude-plugins-official",
"unique_installs": 2615
},
{
"plugin": "figma-mcp@claude-plugins-official",
"unique_installs": 104
},
{
"plugin": "artifact@claude-plugins-official",
"unique_installs": 76
},
{
"plugin": "example-plugin@claude-plugins-official",
"unique_installs": 31
},
{
"plugin": "dart-lsp@claude-plugins-official",
"unique_installs": 4
},
{
"plugin": "ruby-lsp@claude-plugins-official",
"unique_installs": 2
},
{
"plugin": "agent-browser@claude-plugins-official",
"unique_installs": 2
},
{
"plugin": "document-skills@claude-plugins-official",
"unique_installs": 2
},
{
"plugin": "pm@claude-plugins-official",
"unique_installs": 2
},
{
"plugin": "cursor-team-kit@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "codeceptjs-e2e-tests@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "dokploy@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "csharp-roslyn-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "user-journey-analysis@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "review-submission@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "pyrefly-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "vectorhub-memory@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "silince-gutnebrg-builder@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "frontend-lab@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "freshservice@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "ppt-loop@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "test-automation-generator@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "bun-typescript@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "it-triage-system@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "dune@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "apex-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "forge-security@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "n8n-skills@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "grid-design@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "doc-bootstrap@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "claude-memory@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "ai-pm-copilot@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "any-chat-completions@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "hardworking@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "prototyper@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "perlnavigator-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "jira@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "gemini-consult@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "beast-plan@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "spec-writer@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "prd-generator@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "plan-guardian@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "omnisharp-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "frappe-print-format@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "terraform-ls@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "ralph-v2@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "ocpm@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "n8n@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "miro@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "pdf2latex@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "context@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "ewo-discovery-skill@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "amber-electric@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "cds-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "claude-rules-generator@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "docs-search-tool@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "dev-sandbox@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "project-collaboration-system@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "backend-specialist@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "my-time-plugin@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "gitlab-mr-review@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "microsoft-learn@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "hosts-db@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "ida-reverse-engineer@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "aws-diagram@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "creative-music-output-style@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "dj-content-creator@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "gdscript-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "universal-dev@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "vertical-builder@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "latex2cn@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "typescript-native-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "feature-ears@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "hashmind-synapse@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "monday@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "design-principles@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "datadog@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "context-handoff@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "git-release@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "prototype@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "airtable@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "autonomous-loop@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "awesome-claude-skills@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "agent-teams@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "why-how-what-output-style@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "openspec@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "lorikeet-qa@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "memory-agent@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "hello-world@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "codex-skills@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "ccpm@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "continual-learning@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "home-assistant-skills@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "dev-workflow@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "dev-cycle@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "bullet-onboarding@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "vercel-best-practices@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "lean-lsp@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "git-ship@claude-plugins-official",
"unique_installs": 1
},
{
"plugin": "rs-commands@claude-plugins-official",
"unique_installs": 1
}
]
}

View file

@ -0,0 +1 @@
{"servers":[{"server":{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json","name":"agency.lona/trading","description":"AI-powered trading strategy development: backtesting, market data, and portfolio analysis","repository":{"url":"https://github.com/mindsightventures/lona","source":"github","id":"891584339","subfolder":"packages/lona-mcp-server"},"version":"2.0.0","websiteUrl":"https://lona.agency","remotes":[{"type":"streamable-http","url":"https://mcp.lona.agency/mcp"}]},"_meta":{"io.modelcontextprotocol.registry/official":{"status":"active","statusChangedAt":"2026-02-24T00:07:27.525636Z","publishedAt":"2026-02-24T00:07:27.525636Z","updatedAt":"2026-02-24T00:07:27.525636Z","isLatest":true}}},{"server":{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json","name":"ai.adadvisor/mcp-server","description":"Query Meta Ads performance data — accounts, campaigns, ad sets, ads, metrics & settings.","title":"AdAdvisor MCP Server","version":"1.0.0","websiteUrl":"https://www.adadvisor.ai?utm_source=mcp-registry","icons":[{"src":"https://app.adadvisor.ai/adadvisor-logo.png","mimeType":"image/png"}],"remotes":[{"type":"streamable-http","url":"https://api.adadvisor.ai/mcp","headers":[{"description":"Bearer token (adv_sk_...). Get key: https://www.adadvisor.ai/docs/user-guide/managing-api-keys?utm_source=mcp-registry","isRequired":true,"isSecret":true,"name":"Authorization"}]}]},"_meta":{"io.modelcontextprotocol.registry/official":{"status":"active","statusChangedAt":"2026-02-28T12:39:22.82128Z","publishedAt":"2026-02-28T12:39:22.82128Z","updatedAt":"2026-02-28T12:39:22.82128Z","isLatest":false}}},{"server":{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json","name":"ai.adadvisor/mcp-server","description":"Query Meta Ads performance data — accounts, campaigns, ad sets, ads, metrics & settings.","title":"AdAdvisor MCP Server","version":"1.0.1","websiteUrl":"https://www.adadvisor.ai/docs/user-guide/getting-started-with-mcp?utm_source=mcp-registry","icons":[{"src":"https://app.adadvisor.ai/adadvisor-logo.png","mimeType":"image/png"}],"remotes":[{"type":"streamable-http","url":"https://api.adadvisor.ai/mcp","headers":[{"description":"Bearer token (adv_sk_...). Get key: https://www.adadvisor.ai/docs/user-guide/getting-started-with-mcp?utm_source=mcp-registry","isRequired":true,"isSecret":true,"name":"Authorization"}]}]},"_meta":{"io.modelcontextprotocol.registry/official":{"status":"active","statusChangedAt":"2026-02-28T13:09:52.236521Z","publishedAt":"2026-02-28T13:09:52.236521Z","updatedAt":"2026-02-28T13:09:52.236521Z","isLatest":true}}},{"server":{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json","name":"ai.agenttrust/mcp-server","description":"Identity, trust, and A2A orchestration for autonomous AI agents. Official A2A partner.","title":"AgentTrust — Identity & Trust for A2A Agents","repository":{"url":"https://github.com/agenttrust/mcp-server","source":"github"},"version":"1.1.1","websiteUrl":"https://agenttrust.ai","icons":[{"src":"https://agenttrust.ai/icon.png","sizes":["96x96"]}],"packages":[{"registryType":"npm","identifier":"@agenttrust/mcp-server","version":"1.1.1","transport":{"type":"stdio"},"environmentVariables":[{"description":"Your AgentTrust API key from https://agenttrust.ai","isRequired":true,"isSecret":true,"name":"AGENTTRUST_API_KEY"}]}]},"_meta":{"io.modelcontextprotocol.registry/official":{"status":"active","statusChangedAt":"2026-03-06T11:23:10.721165Z","publishedAt":"2026-03-06T11:23:10.721165Z","updatedAt":"2026-03-06T11:23:10.721165Z","isLatest":true}}},{"server":{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json","name":"ai.aliengiraffe/spotdb","description":"Ephemeral data sandbox for AI workflows with guardrails and security","repository":{"url":"https://github.com/aliengiraffe/spotdb","source":"github"},"version":"0.1.0","packages":[{"registryType":"oci","identifier":"docker.io/aliengiraffe/spotdb:0.1.0","transport":{"type":"stdio"},"environmentVariables":[{"description":"Optional API key for request authentication","format":"string","isSecret":true,"name":"X-API-Key"}]}]},"_meta":{"io.modelcontextprotocol.registry/official":{"status":"active","statusChangedAt":"2025-10-09T17:05:17.793149Z","publishedAt":"2025-10-09T17:05:17.793149Z","updatedAt":"2025-10-09T17:05:17.793149Z","isLatest":true}}}],"metadata":{"nextCursor":"ai.aliengiraffe/spotdb:0.1.0","count":5}}

View file

@ -0,0 +1 @@
{"servers":[{"server":{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json","name":"ai.smithery/Hint-Services-obsidian-github-mcp","description":"Connect AI assistants to your GitHub-hosted Obsidian vault to seamlessly access, search, and analy…","repository":{"url":"https://github.com/Hint-Services/obsidian-github-mcp","source":"github"},"version":"0.4.0","remotes":[{"type":"streamable-http","url":"https://server.smithery.ai/@Hint-Services/obsidian-github-mcp/mcp","headers":[{"description":"Bearer token for Smithery authentication","isRequired":true,"value":"Bearer {smithery_api_key}","isSecret":true,"name":"Authorization"}]}]},"_meta":{"io.modelcontextprotocol.registry/official":{"status":"active","statusChangedAt":"2025-09-14T15:20:36.371442Z","publishedAt":"2025-09-14T15:20:36.371442Z","updatedAt":"2025-09-14T15:20:36.371442Z","isLatest":true}}},{"server":{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json","name":"ai.smithery/saidsef-mcp-github-pr-issue-analyser","description":"A Model Context Protocol (MCP) application for automated GitHub PR analysis and issue management.…","repository":{"url":"https://github.com/saidsef/mcp-github-pr-issue-analyser","source":"github"},"version":"1.15.0","remotes":[{"type":"streamable-http","url":"https://server.smithery.ai/@saidsef/mcp-github-pr-issue-analyser/mcp","headers":[{"description":"Bearer token for Smithery authentication","value":"Bearer {smithery_api_key}","name":"Authorization"}]}]},"_meta":{"io.modelcontextprotocol.registry/official":{"status":"active","statusChangedAt":"2025-10-05T14:58:08.898007Z","publishedAt":"2025-10-05T14:58:08.898007Z","updatedAt":"2025-10-05T14:58:08.898007Z","isLatest":true}}},{"server":{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json","name":"ai.smithery/smithery-ai-github","description":"Access the GitHub API, enabling file operations, repository management, search functionality, and…","repository":{"url":"https://github.com/smithery-ai/mcp-servers","source":"github","subfolder":"github"},"version":"1.0.0","remotes":[{"type":"streamable-http","url":"https://server.smithery.ai/@smithery-ai/github/mcp","headers":[{"description":"Bearer token for Smithery authentication","isRequired":true,"value":"Bearer {smithery_api_key}","isSecret":true,"name":"Authorization"}]}]},"_meta":{"io.modelcontextprotocol.registry/official":{"status":"active","statusChangedAt":"2025-09-10T18:22:12.930528Z","publishedAt":"2025-09-10T18:22:12.930528Z","updatedAt":"2025-09-10T18:22:12.930528Z","isLatest":true}}}],"metadata":{"nextCursor":"ai.smithery/smithery-ai-github:1.0.0","count":3}}

View file

@ -0,0 +1,689 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "claude-plugins-official",
"description": "Directory of popular Claude Code extensions including development tools, productivity plugins, and MCP integrations",
"owner": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"plugins": [
{
"name": "typescript-lsp",
"description": "TypeScript/JavaScript language server for enhanced code intelligence",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/typescript-lsp",
"category": "development",
"strict": false,
"lspServers": {
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"],
"extensionToLanguage": {
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".mts": "typescript",
".cts": "typescript",
".mjs": "javascript",
".cjs": "javascript"
}
}
}
},
{
"name": "pyright-lsp",
"description": "Python language server (Pyright) for type checking and code intelligence",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/pyright-lsp",
"category": "development",
"strict": false,
"lspServers": {
"pyright": {
"command": "pyright-langserver",
"args": ["--stdio"],
"extensionToLanguage": {
".py": "python",
".pyi": "python"
}
}
}
},
{
"name": "gopls-lsp",
"description": "Go language server for code intelligence and refactoring",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/gopls-lsp",
"category": "development",
"strict": false,
"lspServers": {
"gopls": {
"command": "gopls",
"extensionToLanguage": {
".go": "go"
}
}
}
},
{
"name": "rust-analyzer-lsp",
"description": "Rust language server for code intelligence and analysis",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/rust-analyzer-lsp",
"category": "development",
"strict": false,
"lspServers": {
"rust-analyzer": {
"command": "rust-analyzer",
"extensionToLanguage": {
".rs": "rust"
}
}
}
},
{
"name": "clangd-lsp",
"description": "C/C++ language server (clangd) for code intelligence",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/clangd-lsp",
"category": "development",
"strict": false,
"lspServers": {
"clangd": {
"command": "clangd",
"args": ["--background-index"],
"extensionToLanguage": {
".c": "c",
".h": "c",
".cpp": "cpp",
".cc": "cpp",
".cxx": "cpp",
".hpp": "cpp",
".hxx": "cpp",
".C": "cpp",
".H": "cpp"
}
}
}
},
{
"name": "php-lsp",
"description": "PHP language server (Intelephense) for code intelligence",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/php-lsp",
"category": "development",
"strict": false,
"lspServers": {
"intelephense": {
"command": "intelephense",
"args": ["--stdio"],
"extensionToLanguage": {
".php": "php"
}
}
}
},
{
"name": "swift-lsp",
"description": "Swift language server (SourceKit-LSP) for code intelligence",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/swift-lsp",
"category": "development",
"strict": false,
"lspServers": {
"sourcekit-lsp": {
"command": "sourcekit-lsp",
"extensionToLanguage": {
".swift": "swift"
}
}
}
},
{
"name": "kotlin-lsp",
"description": "Kotlin language server for code intelligence",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/kotlin-lsp",
"category": "development",
"strict": false,
"lspServers": {
"kotlin-lsp": {
"command": "kotlin-lsp",
"args": ["--stdio"],
"extensionToLanguage": {
".kt": "kotlin",
".kts": "kotlin"
},
"startupTimeout" : 120000
}
}
},
{
"name": "csharp-lsp",
"description": "C# language server for code intelligence",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/csharp-lsp",
"category": "development",
"strict": false,
"lspServers": {
"csharp-ls": {
"command": "csharp-ls",
"extensionToLanguage": {
".cs": "csharp"
}
}
}
},
{
"name": "jdtls-lsp",
"description": "Java language server (Eclipse JDT.LS) for code intelligence",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/jdtls-lsp",
"category": "development",
"strict": false,
"lspServers": {
"jdtls": {
"command": "jdtls",
"extensionToLanguage": {
".java": "java"
},
"startupTimeout": 120000
}
}
},
{
"name": "lua-lsp",
"description": "Lua language server for code intelligence",
"version": "1.0.0",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/lua-lsp",
"category": "development",
"strict": false,
"lspServers": {
"lua": {
"command": "lua-language-server",
"extensionToLanguage": {
".lua": "lua"
}
}
}
},
{
"name": "agent-sdk-dev",
"description": "Development kit for working with the Claude Agent SDK",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/agent-sdk-dev",
"category": "development",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/agent-sdk-dev"
},
{
"name": "pr-review-toolkit",
"description": "Comprehensive PR review agents specializing in comments, tests, error handling, type design, code quality, and code simplification",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/pr-review-toolkit",
"category": "productivity",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/pr-review-toolkit"
},
{
"name": "commit-commands",
"description": "Commands for git commit workflows including commit, push, and PR creation",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/commit-commands",
"category": "productivity",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/commit-commands"
},
{
"name": "feature-dev",
"description": "Comprehensive feature development workflow with specialized agents for codebase exploration, architecture design, and quality review",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/feature-dev",
"category": "development",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/feature-dev"
},
{
"name": "security-guidance",
"description": "Security reminder hook that warns about potential security issues when editing files, including command injection, XSS, and unsafe code patterns",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/security-guidance",
"category": "security",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/security-guidance"
},
{
"name": "code-review",
"description": "Automated code review for pull requests using multiple specialized agents with confidence-based scoring to filter false positives",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/code-review",
"category": "productivity",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/code-review"
},
{
"name": "code-simplifier",
"description": "Agent that simplifies and refines code for clarity, consistency, and maintainability while preserving functionality. Focuses on recently modified code.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/code-simplifier",
"category": "productivity",
"homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/code-simplifier"
},
{
"name": "explanatory-output-style",
"description": "Adds educational insights about implementation choices and codebase patterns (mimics the deprecated Explanatory output style)",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/explanatory-output-style",
"category": "learning",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/explanatory-output-style"
},
{
"name": "learning-output-style",
"description": "Interactive learning mode that requests meaningful code contributions at decision points (mimics the unshipped Learning output style)",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/learning-output-style",
"category": "learning",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/learning-output-style"
},
{
"name": "frontend-design",
"description": "Create distinctive, production-grade frontend interfaces with high design quality. Generates creative, polished code that avoids generic AI aesthetics.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/frontend-design",
"category": "development",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/frontend-design"
},
{
"name": "playground",
"description": "Creates interactive HTML playgrounds — self-contained single-file explorers with visual controls, live preview, and prompt output with copy button. Includes templates for design playgrounds, data explorers, concept maps, and document critique.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/playground",
"category": "development",
"homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/playground"
},
{
"name": "ralph-loop",
"description": "Interactive self-referential AI loops for iterative development, implementing the Ralph Wiggum technique. Claude works on the same task repeatedly, seeing its previous work, until completion.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/ralph-loop",
"category": "development",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/ralph-loop"
},
{
"name": "hookify",
"description": "Easily create custom hooks to prevent unwanted behaviors by analyzing conversation patterns or from explicit instructions. Define rules via simple markdown files.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/hookify",
"category": "productivity",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/hookify"
},
{
"name": "plugin-dev",
"description": "Comprehensive toolkit for developing Claude Code plugins. Includes 7 expert skills covering hooks, MCP integration, commands, agents, and best practices. AI-assisted plugin creation and validation.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/plugin-dev",
"category": "development",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/plugin-dev"
},
{
"name": "claude-code-setup",
"description": "Analyze codebases and recommend tailored Claude Code automations such as hooks, skills, MCP servers, and subagents.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/claude-code-setup",
"category": "productivity",
"homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/claude-code-setup"
},
{
"name": "claude-md-management",
"description": "Tools to maintain and improve CLAUDE.md files - audit quality, capture session learnings, and keep project memory current.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/claude-md-management",
"category": "productivity",
"homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/claude-md-management"
},
{
"name": "skill-creator",
"description": "Create new skills, improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, update or optimize an existing skill, run evals to test a skill, or benchmark skill performance with variance analysis.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/skill-creator",
"category": "development",
"homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/skill-creator"
},
{
"name": "greptile",
"description": "AI-powered codebase search and understanding. Query your repositories using natural language to find relevant code, understand dependencies, and get contextual answers about your codebase architecture.",
"category": "development",
"source": "./external_plugins/greptile",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/greptile"
},
{
"name": "serena",
"description": "Semantic code analysis MCP server providing intelligent code understanding, refactoring suggestions, and codebase navigation through language server protocol integration.",
"category": "development",
"source": "./external_plugins/serena",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/serena",
"tags": ["community-managed"]
},
{
"name": "playwright",
"description": "Browser automation and end-to-end testing MCP server by Microsoft. Enables Claude to interact with web pages, take screenshots, fill forms, click elements, and perform automated browser testing workflows.",
"category": "testing",
"source": "./external_plugins/playwright",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/playwright"
},
{
"name": "github",
"description": "Official GitHub MCP server for repository management. Create issues, manage pull requests, review code, search repositories, and interact with GitHub's full API directly from Claude Code.",
"category": "productivity",
"source": "./external_plugins/github",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/github"
},
{
"name": "supabase",
"description": "Supabase MCP integration for database operations, authentication, storage, and real-time subscriptions. Manage your Supabase projects, run SQL queries, and interact with your backend directly.",
"category": "database",
"source": "./external_plugins/supabase",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/supabase"
},
{
"name": "atlassian",
"description": "Connect to Atlassian products including Jira and Confluence. Search and create issues, access documentation, manage sprints, and integrate your development workflow with Atlassian's collaboration tools.",
"category": "productivity",
"source": {
"source": "url",
"url": "https://github.com/atlassian/atlassian-mcp-server.git"
},
"homepage": "https://github.com/atlassian/atlassian-mcp-server"
},
{
"name": "laravel-boost",
"description": "Laravel development toolkit MCP server. Provides intelligent assistance for Laravel applications including Artisan commands, Eloquent queries, routing, migrations, and framework-specific code generation.",
"category": "development",
"source": "./external_plugins/laravel-boost",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/laravel-boost"
},
{
"name": "figma",
"description": "Figma design platform integration. Access design files, extract component information, read design tokens, and translate designs into code. Bridge the gap between design and development workflows.",
"category": "design",
"source": {
"source": "url",
"url": "https://github.com/figma/mcp-server-guide.git"
},
"homepage": "https://github.com/figma/mcp-server-guide"
},
{
"name": "asana",
"description": "Asana project management integration. Create and manage tasks, search projects, update assignments, track progress, and integrate your development workflow with Asana's work management platform.",
"category": "productivity",
"source": "./external_plugins/asana",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/asana"
},
{
"name": "linear",
"description": "Linear issue tracking integration. Create issues, manage projects, update statuses, search across workspaces, and streamline your software development workflow with Linear's modern issue tracker.",
"category": "productivity",
"source": "./external_plugins/linear",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/linear"
},
{
"name": "Notion",
"description": "Notion workspace integration. Search pages, create and update documents, manage databases, and access your team's knowledge base directly from Claude Code for seamless documentation workflows.",
"category": "productivity",
"source": {
"source": "url",
"url": "https://github.com/makenotion/claude-code-notion-plugin.git"
},
"homepage": "https://github.com/makenotion/claude-code-notion-plugin"
},
{
"name": "gitlab",
"description": "GitLab DevOps platform integration. Manage repositories, merge requests, CI/CD pipelines, issues, and wikis. Full access to GitLab's comprehensive DevOps lifecycle tools.",
"category": "productivity",
"source": "./external_plugins/gitlab",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/gitlab"
},
{
"name": "sentry",
"description": "Sentry error monitoring integration. Access error reports, analyze stack traces, search issues by fingerprint, and debug production errors directly from your development environment.",
"category": "monitoring",
"source": {
"source": "url",
"url": "https://github.com/getsentry/sentry-for-claude.git"
},
"homepage": "https://github.com/getsentry/sentry-for-claude/tree/main"
},
{
"name": "slack",
"description": "Slack workspace integration. Search messages, access channels, read threads, and stay connected with your team's communications while coding. Find relevant discussions and context quickly.",
"category": "productivity",
"source": {
"source": "url",
"url": "https://github.com/slackapi/slack-mcp-plugin.git"
},
"homepage": "https://github.com/slackapi/slack-mcp-plugin/tree/main"
},
{
"name": "vercel",
"description": "Vercel deployment platform integration. Manage deployments, check build status, access logs, configure domains, and control your frontend infrastructure directly from Claude Code.",
"category": "deployment",
"source": {
"source": "url",
"url": "https://github.com/vercel/vercel-deploy-claude-code-plugin.git"
},
"homepage": "https://github.com/vercel/vercel-deploy-claude-code-plugin"
},
{
"name": "stripe",
"description": "Stripe development plugin for Claude",
"category": "development",
"source": "./external_plugins/stripe",
"homepage": "https://github.com/stripe/ai/tree/main/providers/claude/plugin"
},
{
"name": "firebase",
"description": "Google Firebase MCP integration. Manage Firestore databases, authentication, cloud functions, hosting, and storage. Build and manage your Firebase backend directly from your development workflow.",
"category": "database",
"source": "./external_plugins/firebase",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/firebase"
},
{
"name": "context7",
"description": "Upstash Context7 MCP server for up-to-date documentation lookup. Pull version-specific documentation and code examples directly from source repositories into your LLM context.",
"category": "development",
"source": "./external_plugins/context7",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/context7",
"tags": ["community-managed"]
},
{
"name": "pinecone",
"description": "Pinecone vector database integration. Streamline your Pinecone development with powerful tools for managing vector indexes, querying data, and rapid prototyping. Use slash commands like /quickstart to generate AGENTS.md files and initialize Python projects and /query to quickly explore indexes. Access the Pinecone MCP server for creating, describing, upserting and querying indexes with Claude. Perfect for developers building semantic search, RAG applications, recommendation systems, and other vector-based applications with Pinecone.",
"category": "database",
"source": {
"source": "url",
"url": "https://github.com/pinecone-io/pinecone-claude-code-plugin.git"
},
"homepage": "https://github.com/pinecone-io/pinecone-claude-code-plugin"
},
{
"name": "huggingface-skills",
"description": "Build, train, evaluate, and use open source AI models, datasets, and spaces.",
"category": "development",
"source": {
"source": "url",
"url": "https://github.com/huggingface/skills.git"
},
"homepage": "https://github.com/huggingface/skills.git"
},
{
"name": "circleback",
"description": "Circleback conversational context integration. Search and access meetings, emails, calendar events, and more.",
"category": "productivity",
"source": {
"source": "url",
"url": "https://github.com/circlebackai/claude-code-plugin.git"
},
"homepage": "https://github.com/circlebackai/claude-code-plugin.git"
},
{
"name": "superpowers",
"description": "Superpowers teaches Claude brainstorming, subagent driven development with built in code review, systematic debugging, and red/green TDD. Additionally, it teaches Claude how to author and test new skills.",
"category": "development",
"source": {
"source": "url",
"url": "https://github.com/obra/superpowers.git"
},
"homepage": "https://github.com/obra/superpowers.git"
},
{
"name": "posthog",
"description": "Connect Claude Code to your PostHog analytics platform. Query insights, manage feature flags, run A/B experiments, track errors, and analyze LLM costs all through natural language. The plugin provides 10 slash commands for common workflows and full access to PostHog's MCP tools. Ask questions like \"What are my top errors?\" or \"Create a feature flag for 50% of users\" and Claude handles the API calls. Supports OAuth authentication, EU and US cloud regions, and self-hosted instances.",
"category": "monitoring",
"source": {
"source": "url",
"url": "https://github.com/PostHog/posthog-for-claude.git"
},
"homepage": "https://github.com/PostHog/posthog-for-claude.git"
},
{
"name": "coderabbit",
"description": "Your code review partner. CodeRabbit provides external validation using a specialized AI architecture and 40+ integrated static analyzers—offering a different perspective that catches bugs, security vulnerabilities, logic errors, and edge cases. Context-aware analysis via AST parsing and codegraph relationships. Automatically incorporates CLAUDE.md and project coding guidelines into reviews. Useful after writing or modifying code, before commits, when implementing complex or security-sensitive logic, or when a second opinion would increase confidence in the changes. Returns specific findings with suggested fixes that can be applied immediately. Free to use.",
"category": "productivity",
"source": {
"source": "url",
"url": "https://github.com/coderabbitai/claude-plugin.git"
},
"homepage": "https://github.com/coderabbitai/claude-plugin.git"
},
{
"name": "sonatype-guide",
"description": "Sonatype Guide MCP server for software supply chain intelligence and dependency security. Analyze dependencies for vulnerabilities, get secure version recommendations, and check component quality metrics.",
"category": "security",
"source": {
"source": "url",
"url": "https://github.com/sonatype/sonatype-guide-claude-plugin.git"
},
"homepage": "https://github.com/sonatype/sonatype-guide-claude-plugin.git"
},
{
"name": "firecrawl",
"description": "Web scraping and crawling powered by Firecrawl. Turn any website into clean, LLM-ready markdown or structured data. Scrape single pages, crawl entire sites, search the web, and extract structured information. Includes an AI agent for autonomous multi-source data gathering - just describe what you need and it finds, navigates, and extracts automatically.",
"category": "development",
"source": {
"source": "url",
"url": "https://github.com/firecrawl/firecrawl-claude-plugin.git"
},
"homepage": "https://github.com/firecrawl/firecrawl-claude-plugin.git"
},
{
"name": "qodo-skills",
"description": "Qodo Skills provides a curated library of reusable AI agent capabilities that extend Claude's functionality for software development workflows. Each skill is designed to integrate seamlessly into your development process, enabling tasks like code quality checks, automated testing, security scanning, and compliance validation. Skills operate across your entire SDLC—from IDE to CI/CD—ensuring consistent standards and catching issues early.",
"category": "development",
"source": {
"source": "url",
"url": "https://github.com/qodo-ai/qodo-skills.git",
"sha": "623eb4ed4364d8111f9a9132a791d7497d814b6a"
},
"homepage": "https://github.com/qodo-ai/qodo-skills.git"
},
{
"name": "semgrep",
"description": "Semgrep catches security vulnerabilities in real-time and guides Claude to write secure code from the start.",
"category": "security",
"source": {
"source": "url",
"url": "https://github.com/semgrep/mcp-marketplace.git"
},
"homepage": "https://github.com/semgrep/mcp-marketplace.git"
}
]
}

View file

@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { McpCatalogAggregator } from '@main/services/extensions/catalog/McpCatalogAggregator';
import { OfficialMcpRegistryService } from '@main/services/extensions/catalog/OfficialMcpRegistryService';
import { GlamaMcpEnrichmentService } from '@main/services/extensions/catalog/GlamaMcpEnrichmentService';
import type { McpCatalogItem } from '@shared/types/extensions';
describe('McpCatalogAggregator', () => {
let aggregator: McpCatalogAggregator;
let official: OfficialMcpRegistryService;
let glama: GlamaMcpEnrichmentService;
const makeItem = (overrides: Partial<McpCatalogItem>): McpCatalogItem => ({
id: 'test-id',
name: 'test',
description: 'test desc',
source: 'official',
installSpec: null,
envVars: [],
tools: [],
requiresAuth: false,
...overrides,
});
beforeEach(() => {
official = new OfficialMcpRegistryService();
glama = new GlamaMcpEnrichmentService();
aggregator = new McpCatalogAggregator(official, glama);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('search', () => {
it('merges results from both sources', async () => {
const officialItem = makeItem({
id: 'io.example/server',
name: 'Example',
source: 'official',
repositoryUrl: 'https://github.com/example/server',
});
const glamaItem = makeItem({
id: 'glama:abc123',
name: 'glama-only',
source: 'glama',
repositoryUrl: 'https://github.com/glama/only',
});
vi.spyOn(official, 'search').mockResolvedValue([officialItem]);
vi.spyOn(glama, 'search').mockResolvedValue([glamaItem]);
const result = await aggregator.search('test');
expect(result.servers).toHaveLength(2);
expect(result.warnings).toEqual([]);
});
it('reports warning when official registry fails', async () => {
vi.spyOn(official, 'search').mockRejectedValue(new Error('timeout'));
vi.spyOn(glama, 'search').mockResolvedValue([makeItem({ id: 'glama:1', source: 'glama' })]);
const result = await aggregator.search('test');
expect(result.servers).toHaveLength(1);
expect(result.warnings).toContain('Official MCP Registry unavailable');
});
it('reports warning when glama fails', async () => {
vi.spyOn(official, 'search').mockResolvedValue([makeItem({ id: 'off1', source: 'official' })]);
vi.spyOn(glama, 'search').mockRejectedValue(new Error('timeout'));
const result = await aggregator.search('test');
expect(result.servers).toHaveLength(1);
expect(result.warnings).toContain('Glama enrichment unavailable');
});
it('deduplicates by repository URL (official takes priority)', async () => {
const repo = 'https://github.com/shared/repo';
const officialItem = makeItem({
id: 'io.shared/repo',
name: 'Official',
source: 'official',
repositoryUrl: repo,
});
const glamaItem = makeItem({
id: 'glama:shared',
name: 'Glama',
source: 'glama',
repositoryUrl: repo,
license: 'MIT',
});
vi.spyOn(official, 'search').mockResolvedValue([officialItem]);
vi.spyOn(glama, 'search').mockResolvedValue([glamaItem]);
const result = await aggregator.search('test');
// Should have 1 (official), not 2
expect(result.servers).toHaveLength(1);
expect(result.servers[0].source).toBe('official');
// Enriched with Glama license
expect(result.servers[0].license).toBe('MIT');
});
it('handles case-insensitive repo URL dedup', async () => {
const officialItem = makeItem({
id: 'io.example/repo',
source: 'official',
repositoryUrl: 'https://GitHub.com/Example/Repo.git',
});
const glamaItem = makeItem({
id: 'glama:x',
source: 'glama',
repositoryUrl: 'https://github.com/example/repo',
});
vi.spyOn(official, 'search').mockResolvedValue([officialItem]);
vi.spyOn(glama, 'search').mockResolvedValue([glamaItem]);
const result = await aggregator.search('test');
expect(result.servers).toHaveLength(1);
});
});
describe('getById', () => {
it('delegates to official for non-glama IDs', async () => {
const item = makeItem({ id: 'io.example/server' });
vi.spyOn(official, 'getById').mockResolvedValue(item);
const result = await aggregator.getById('io.example/server');
expect(result).toBe(item);
expect(official.getById).toHaveBeenCalledWith('io.example/server');
});
it('returns null for glama IDs (cannot auto-install)', async () => {
const result = await aggregator.getById('glama:abc123');
expect(result).toBeNull();
});
});
});

View file

@ -0,0 +1,296 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { McpInstallService } from '@main/services/extensions/install/McpInstallService';
import type { McpCatalogAggregator } from '@main/services/extensions/catalog/McpCatalogAggregator';
import type { McpCatalogItem } from '@shared/types/extensions';
// ── Mock execCli ─────────────────────────────────────────────────────────────
vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(),
}));
import { execCli } from '@main/utils/childProcess';
const mockExecCli = vi.mocked(execCli);
// ── Mock aggregator ──────────────────────────────────────────────────────────
function makeStdioServer(): McpCatalogItem {
return {
id: 'upstash/context7-mcp',
name: 'Context7 MCP',
description: 'Context-aware MCP server',
source: 'official',
installSpec: {
type: 'stdio',
npmPackage: '@upstash/context7-mcp',
npmVersion: '1.0.0',
},
envVars: [{ name: 'UPSTASH_API_KEY', isSecret: true }],
tools: [],
requiresAuth: false,
};
}
function makeHttpServer(): McpCatalogItem {
return {
id: 'example/http-server',
name: 'Example HTTP',
description: 'HTTP MCP server',
source: 'official',
installSpec: {
type: 'http',
url: 'https://mcp.example.com/sse',
transportType: 'sse',
},
envVars: [],
tools: [],
requiresAuth: true,
};
}
function createMockAggregator(
getByIdResult: McpCatalogItem | null = makeStdioServer(),
): McpCatalogAggregator {
return {
search: vi.fn(),
browse: vi.fn(),
getById: vi.fn().mockResolvedValue(getByIdResult),
} as unknown as McpCatalogAggregator;
}
describe('McpInstallService', () => {
let service: McpInstallService;
let aggregator: McpCatalogAggregator;
beforeEach(() => {
vi.clearAllMocks();
aggregator = createMockAggregator();
service = new McpInstallService(null, aggregator);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── install: stdio ──────────────────────────────────────────────────────────
describe('install (stdio)', () => {
it('builds correct CLI args for stdio server', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
const result = await service.install({
registryId: 'upstash/context7-mcp',
serverName: 'context7',
scope: 'user',
envValues: { UPSTASH_API_KEY: 'test-key-123' },
headers: [],
});
expect(result.state).toBe('success');
expect(mockExecCli).toHaveBeenCalledWith(
null,
['mcp', 'add', '-s', 'user', '-e', 'UPSTASH_API_KEY=test-key-123', 'context7', '--', 'npx', '-y', '@upstash/context7-mcp@1.0.0'],
expect.objectContaining({ timeout: 30_000 }),
);
});
it('adds scope flag for project scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
await service.install({
registryId: 'upstash/context7-mcp',
serverName: 'context7',
scope: 'project',
projectPath: '/tmp/test',
envValues: {},
headers: [],
});
const args = mockExecCli.mock.calls[0]?.[1];
expect(args).toContain('-s');
expect(args).toContain('project');
});
it('does NOT add scope flag for local scope (default)', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
await service.install({
registryId: 'upstash/context7-mcp',
serverName: 'context7',
scope: 'local',
envValues: {},
headers: [],
});
const args = mockExecCli.mock.calls[0]?.[1];
expect(args).not.toContain('-s');
});
});
// ── install: http ───────────────────────────────────────────────────────────
describe('install (http)', () => {
it('builds correct CLI args for HTTP server', async () => {
aggregator = createMockAggregator(makeHttpServer());
service = new McpInstallService(null, aggregator);
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
const result = await service.install({
registryId: 'example/http-server',
serverName: 'example-http',
scope: 'user',
envValues: {},
headers: [{ key: 'Authorization', value: 'Bearer token123' }],
});
expect(result.state).toBe('success');
expect(mockExecCli).toHaveBeenCalledWith(
null,
['mcp', 'add', '-s', 'user', '-t', 'sse', '-H', 'Authorization: Bearer token123', 'example-http', 'https://mcp.example.com/sse'],
expect.objectContaining({ timeout: 30_000 }),
);
});
});
// ── install: validation ─────────────────────────────────────────────────────
describe('install (validation)', () => {
it('rejects invalid server name', async () => {
const result = await service.install({
registryId: 'test',
serverName: '../etc/passwd',
scope: 'user',
envValues: {},
headers: [],
});
expect(result.state).toBe('error');
expect(result.error).toContain('Invalid server name');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('returns error if server not found in registry', async () => {
aggregator = createMockAggregator(null);
service = new McpInstallService(null, aggregator);
const result = await service.install({
registryId: 'nonexistent',
serverName: 'test',
scope: 'user',
envValues: {},
headers: [],
});
expect(result.state).toBe('error');
expect(result.error).toContain('not found in registry');
});
it('returns error if server has no installSpec', async () => {
const serverNoSpec: McpCatalogItem = {
...makeStdioServer(),
installSpec: null,
};
aggregator = createMockAggregator(serverNoSpec);
service = new McpInstallService(null, aggregator);
const result = await service.install({
registryId: 'test',
serverName: 'test',
scope: 'user',
envValues: {},
headers: [],
});
expect(result.state).toBe('error');
expect(result.error).toContain('Manual setup required');
});
});
// ── install: error masking ──────────────────────────────────────────────────
describe('install (secret masking)', () => {
it('masks env values in error messages', async () => {
mockExecCli.mockRejectedValue(
new Error('Command failed: UPSTASH_API_KEY=super-secret-key-12345'),
);
const result = await service.install({
registryId: 'test',
serverName: 'context7',
scope: 'user',
envValues: { UPSTASH_API_KEY: 'super-secret-key-12345' },
headers: [],
});
expect(result.state).toBe('error');
expect(result.error).not.toContain('super-secret-key-12345');
expect(result.error).toContain('[REDACTED]');
});
it('masks header values in error messages', async () => {
aggregator = createMockAggregator(makeHttpServer());
service = new McpInstallService(null, aggregator);
mockExecCli.mockRejectedValue(
new Error('Auth failed with Bearer my-token-value'),
);
const result = await service.install({
registryId: 'test',
serverName: 'example',
scope: 'user',
envValues: {},
headers: [{ key: 'Authorization', value: 'Bearer my-token-value' }],
});
expect(result.state).toBe('error');
expect(result.error).not.toContain('Bearer my-token-value');
});
});
// ── uninstall ───────────────────────────────────────────────────────────────
describe('uninstall', () => {
it('builds correct CLI args', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
const result = await service.uninstall('context7');
expect(result.state).toBe('success');
expect(mockExecCli).toHaveBeenCalledWith(
null,
['mcp', 'remove', 'context7'],
expect.objectContaining({ timeout: 30_000 }),
);
});
it('adds scope flag for user scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
await service.uninstall('context7', 'user');
const args = mockExecCli.mock.calls[0]?.[1];
expect(args).toContain('-s');
expect(args).toContain('user');
});
it('rejects invalid server name', async () => {
const result = await service.uninstall('$(rm -rf /)');
expect(result.state).toBe('error');
expect(result.error).toContain('Invalid server name');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('returns error on CLI failure', async () => {
mockExecCli.mockRejectedValue(new Error('Not found'));
const result = await service.uninstall('context7');
expect(result.state).toBe('error');
expect(result.error).toContain('Not found');
});
});
});

View file

@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OfficialMcpRegistryService } from '@main/services/extensions/catalog/OfficialMcpRegistryService';
import registryListFixture from '../../../fixtures/extensions/official-mcp-registry-list.json';
import registrySearchFixture from '../../../fixtures/extensions/official-mcp-registry-search.json';
// ── Mock HTTP ──────────────────────────────────────────────────────────────
vi.mock('node:https', () => ({
default: { get: vi.fn() },
get: vi.fn(),
}));
vi.mock('node:http', () => ({
default: { get: vi.fn() },
get: vi.fn(),
}));
import https from 'node:https';
import type { IncomingMessage } from 'node:http';
function mockHttpsGet(statusCode: number, body: string): void {
const mockGet = https.get as ReturnType<typeof vi.fn>;
mockGet.mockImplementation((_url: string, callback: (res: IncomingMessage) => void) => {
const res = {
statusCode,
headers: {},
on: vi.fn((event: string, handler: (data?: Buffer) => void) => {
if (event === 'data') handler(Buffer.from(body));
if (event === 'end') handler();
return res;
}),
destroy: vi.fn(),
};
callback(res as unknown as IncomingMessage);
return {
setTimeout: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
};
});
}
describe('OfficialMcpRegistryService', () => {
let service: OfficialMcpRegistryService;
beforeEach(() => {
service = new OfficialMcpRegistryService();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('search', () => {
it('returns normalized MCP servers from search results', async () => {
mockHttpsGet(200, JSON.stringify(registrySearchFixture));
const results = await service.search('github');
expect(results.length).toBeGreaterThan(0);
expect(results[0]).toHaveProperty('id');
expect(results[0]).toHaveProperty('name');
expect(results[0]).toHaveProperty('source', 'official');
});
it('returns empty array on network error', async () => {
const mockGet = https.get as ReturnType<typeof vi.fn>;
mockGet.mockImplementation((_url: string, _callback: unknown) => {
return {
setTimeout: vi.fn(),
on: vi.fn((event: string, handler: (err: Error) => void) => {
if (event === 'error') handler(new Error('Network error'));
}),
destroy: vi.fn(),
};
});
const results = await service.search('test');
expect(results).toEqual([]);
});
});
describe('browse', () => {
it('returns servers with pagination cursor', async () => {
mockHttpsGet(200, JSON.stringify(registryListFixture));
const result = await service.browse();
expect(result.servers.length).toBeGreaterThan(0);
expect(result.nextCursor).toBeDefined();
});
it('filters non-latest versions', async () => {
mockHttpsGet(200, JSON.stringify(registryListFixture));
const result = await service.browse();
// Registry fixture has AdAdvisor with isLatest: false and isLatest: true
// Only the latest should appear
const adAdvisor = result.servers.filter((s) =>
s.id === 'ai.adadvisor/mcp-server',
);
expect(adAdvisor.length).toBeLessThanOrEqual(1);
});
});
describe('normalizeEntry', () => {
it('derives stdio install spec from npm packages', async () => {
mockHttpsGet(200, JSON.stringify(registryListFixture));
const result = await service.browse();
const agentTrust = result.servers.find((s) => s.id === 'ai.agenttrust/mcp-server');
expect(agentTrust).toBeDefined();
expect(agentTrust!.installSpec).toEqual({
type: 'stdio',
npmPackage: '@agenttrust/mcp-server',
npmVersion: '1.1.1',
});
});
it('derives HTTP install spec from remotes', async () => {
mockHttpsGet(200, JSON.stringify(registryListFixture));
const result = await service.browse();
const lona = result.servers.find((s) => s.id === 'agency.lona/trading');
expect(lona).toBeDefined();
expect(lona!.installSpec).toEqual({
type: 'http',
url: 'https://mcp.lona.agency/mcp',
transportType: 'streamable-http',
});
});
it('detects auth-required servers', async () => {
mockHttpsGet(200, JSON.stringify(registryListFixture));
const result = await service.browse();
const adAdvisor = result.servers.find((s) => s.id === 'ai.adadvisor/mcp-server');
expect(adAdvisor?.requiresAuth).toBe(true);
});
it('collects environment variables', async () => {
mockHttpsGet(200, JSON.stringify(registryListFixture));
const result = await service.browse();
const agentTrust = result.servers.find((s) => s.id === 'ai.agenttrust/mcp-server');
expect(agentTrust!.envVars).toEqual([
{
name: 'AGENTTRUST_API_KEY',
isSecret: true,
description: 'Your AgentTrust API key from https://agenttrust.ai',
isRequired: true,
},
]);
});
});
});

View file

@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PluginCatalogService } from '@main/services/extensions/catalog/PluginCatalogService';
// Read fixtures
import marketplaceFixture from '../../../fixtures/extensions/plugin-marketplace.json';
// ── Mock HTTP ──────────────────────────────────────────────────────────────
// We mock the http/https modules at the bottom level by mocking the service's
// internal fetch method. Instead, we'll test via the public API by mocking
// the global https/http modules.
vi.mock('node:https', () => ({
default: { get: vi.fn() },
get: vi.fn(),
}));
vi.mock('node:http', () => ({
default: { get: vi.fn() },
get: vi.fn(),
}));
import https from 'node:https';
import type { IncomingMessage } from 'node:http';
/**
* Helper to mock https.get to return a fake response.
*/
function mockHttpsGet(
statusCode: number,
body: string,
headers: Record<string, string> = {},
): void {
const mockGet = https.get as ReturnType<typeof vi.fn>;
mockGet.mockImplementation((_url: string, _opts: unknown, callback: (res: IncomingMessage) => void) => {
const res = {
statusCode,
headers,
on: vi.fn((event: string, handler: (data?: Buffer) => void) => {
if (event === 'data') handler(Buffer.from(body));
if (event === 'end') handler();
return res;
}),
destroy: vi.fn(),
};
callback(res as unknown as IncomingMessage);
return {
setTimeout: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
};
});
}
describe('PluginCatalogService', () => {
let service: PluginCatalogService;
beforeEach(() => {
service = new PluginCatalogService();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getPlugins', () => {
it('fetches and parses marketplace.json into PluginCatalogItem[]', async () => {
mockHttpsGet(200, JSON.stringify(marketplaceFixture), { etag: '"abc123"' });
const plugins = await service.getPlugins();
expect(plugins.length).toBe(marketplaceFixture.plugins.length);
expect(plugins[0].pluginId).toBe('typescript-lsp@claude-plugins-official');
expect(plugins[0].qualifiedName).toBe('typescript-lsp@claude-plugins-official');
expect(plugins[0].name).toBe('typescript-lsp');
expect(plugins[0].description).toBe(
'TypeScript/JavaScript language server for enhanced code intelligence',
);
expect(plugins[0].category).toBe('development');
expect(plugins[0].hasLspServers).toBe(true);
expect(plugins[0].hasMcpServers).toBe(false);
expect(plugins[0].isExternal).toBe(false);
});
it('detects external plugins (source is object with URL)', async () => {
mockHttpsGet(200, JSON.stringify(marketplaceFixture), {});
const plugins = await service.getPlugins();
const atlassian = plugins.find((p) => p.name === 'atlassian');
expect(atlassian).toBeDefined();
expect(atlassian!.isExternal).toBe(true);
expect(atlassian!.homepage).toBe(
'https://github.com/atlassian/atlassian-mcp-server',
);
});
it('returns cached data within TTL', async () => {
mockHttpsGet(200, JSON.stringify(marketplaceFixture), {});
const first = await service.getPlugins();
const second = await service.getPlugins();
// Only one HTTP call
expect(https.get).toHaveBeenCalledTimes(1);
expect(first).toBe(second);
});
it('uses ETag for conditional requests after TTL expires', async () => {
// First fetch
mockHttpsGet(200, JSON.stringify(marketplaceFixture), { etag: '"v1"' });
await service.getPlugins();
// Expire TTL
// Access private cache to force expiry
const cacheField = (service as unknown as Record<string, { fetchedAt: number } | null>)['cache'];
if (cacheField) cacheField.fetchedAt = 0;
// Second fetch — 304 Not Modified
mockHttpsGet(304, '', {});
const plugins = await service.getPlugins();
expect(plugins.length).toBe(marketplaceFixture.plugins.length);
});
it('falls back to stale cache on network error', async () => {
// First: successful fetch
mockHttpsGet(200, JSON.stringify(marketplaceFixture), {});
await service.getPlugins();
// Expire TTL
const cacheField2 = (service as unknown as Record<string, { fetchedAt: number } | null>)['cache'];
if (cacheField2) cacheField2.fetchedAt = 0;
// Second: network error
const mockGet = https.get as ReturnType<typeof vi.fn>;
mockGet.mockImplementation((_url: string, _opts: unknown, _callback: unknown) => {
return {
setTimeout: vi.fn(),
on: vi.fn((event: string, handler: (err: Error) => void) => {
if (event === 'error') handler(new Error('Network error'));
}),
destroy: vi.fn(),
};
});
const plugins = await service.getPlugins();
expect(plugins.length).toBe(marketplaceFixture.plugins.length);
});
it('throws when no cache and network fails', async () => {
const mockGet = https.get as ReturnType<typeof vi.fn>;
mockGet.mockImplementation((_url: string, _opts: unknown, _callback: unknown) => {
return {
setTimeout: vi.fn(),
on: vi.fn((event: string, handler: (err: Error) => void) => {
if (event === 'error') handler(new Error('Network error'));
}),
destroy: vi.fn(),
};
});
await expect(service.getPlugins()).rejects.toThrow('Network error');
});
it('deduplicates concurrent requests', async () => {
mockHttpsGet(200, JSON.stringify(marketplaceFixture), {});
const [a, b] = await Promise.all([service.getPlugins(), service.getPlugins()]);
expect(https.get).toHaveBeenCalledTimes(1);
expect(a).toBe(b);
});
});
describe('resolvePlugin', () => {
it('returns plugin by pluginId', async () => {
mockHttpsGet(200, JSON.stringify(marketplaceFixture), {});
const plugin = await service.resolvePlugin('typescript-lsp@claude-plugins-official');
expect(plugin).toBeDefined();
expect(plugin!.name).toBe('typescript-lsp');
});
it('returns null for unknown pluginId', async () => {
mockHttpsGet(200, JSON.stringify(marketplaceFixture), {});
const plugin = await service.resolvePlugin('nonexistent@marketplace');
expect(plugin).toBeNull();
});
});
});

View file

@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PluginInstallService } from '@main/services/extensions/install/PluginInstallService';
import type { PluginCatalogService } from '@main/services/extensions/catalog/PluginCatalogService';
// ── Mock execCli ─────────────────────────────────────────────────────────────
vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(),
}));
import { execCli } from '@main/utils/childProcess';
const mockExecCli = vi.mocked(execCli);
// ── Mock catalog service ──────────────────────────────────────────────────────
function createMockCatalog(overrides?: Partial<PluginCatalogService>): PluginCatalogService {
return {
getPlugins: vi.fn(),
getPluginReadme: vi.fn(),
resolvePlugin: vi.fn().mockResolvedValue({
qualifiedName: 'context7@claude-plugins-official',
}),
...overrides,
} as unknown as PluginCatalogService;
}
describe('PluginInstallService', () => {
let service: PluginInstallService;
let catalog: PluginCatalogService;
beforeEach(() => {
vi.clearAllMocks();
catalog = createMockCatalog();
service = new PluginInstallService(null, catalog);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── install ─────────────────────────────────────────────────────────────────
describe('install', () => {
it('builds correct CLI args for user scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
const result = await service.install({
pluginId: 'context7',
scope: 'user',
});
expect(result.state).toBe('success');
expect(mockExecCli).toHaveBeenCalledWith(
null,
['plugin', 'install', 'context7@claude-plugins-official'],
expect.objectContaining({ timeout: 120_000 }),
);
});
it('adds scope flag for non-user scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
await service.install({
pluginId: 'context7',
scope: 'project',
projectPath: '/tmp/test-project',
});
expect(mockExecCli).toHaveBeenCalledWith(
null,
['plugin', 'install', '-s', 'project', 'context7@claude-plugins-official'],
expect.objectContaining({ cwd: '/tmp/test-project' }),
);
});
it('returns error if plugin not found in catalog', async () => {
catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
});
service = new PluginInstallService(null, catalog);
const result = await service.install({ pluginId: 'nonexistent', scope: 'user' });
expect(result.state).toBe('error');
expect(result.error).toContain('not found in catalog');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('returns error if qualifiedName has invalid format', async () => {
catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue({
qualifiedName: '../../../etc/passwd',
}) as PluginCatalogService['resolvePlugin'],
});
service = new PluginInstallService(null, catalog);
const result = await service.install({ pluginId: 'evil', scope: 'user' });
expect(result.state).toBe('error');
expect(result.error).toContain('Invalid plugin identifier');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('returns error if CLI execution fails', async () => {
mockExecCli.mockRejectedValue(new Error('Command failed: exit code 1'));
const result = await service.install({ pluginId: 'context7', scope: 'user' });
expect(result.state).toBe('error');
expect(result.error).toContain('Command failed');
});
});
// ── uninstall ───────────────────────────────────────────────────────────────
describe('uninstall', () => {
it('builds correct CLI args for user scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
const result = await service.uninstall('context7');
expect(result.state).toBe('success');
expect(mockExecCli).toHaveBeenCalledWith(
null,
['plugin', 'uninstall', 'context7@claude-plugins-official'],
expect.objectContaining({ timeout: 30_000 }),
);
});
it('adds scope flag for project scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
await service.uninstall('context7', 'project', '/tmp/test-project');
expect(mockExecCli).toHaveBeenCalledWith(
null,
['plugin', 'uninstall', '-s', 'project', 'context7@claude-plugins-official'],
expect.objectContaining({ cwd: '/tmp/test-project' }),
);
});
it('returns error if plugin not in catalog', async () => {
catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
});
service = new PluginInstallService(null, catalog);
const result = await service.uninstall('nonexistent');
expect(result.state).toBe('error');
expect(result.error).toContain('not found in catalog');
});
it('returns error if CLI fails', async () => {
mockExecCli.mockRejectedValue(new Error('Cannot uninstall'));
const result = await service.uninstall('context7');
expect(result.state).toBe('error');
expect(result.error).toContain('Cannot uninstall');
});
});
});

View file

@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import { PluginInstallationStateService } from '@main/services/extensions/state/PluginInstallationStateService';
// Mock pathDecoder to control ~/.claude path
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => '/tmp/mock-claude',
}));
// Mock filesystem
vi.mock('node:fs/promises');
describe('PluginInstallationStateService', () => {
let service: PluginInstallationStateService;
const mockedFs = vi.mocked(fs);
beforeEach(() => {
service = new PluginInstallationStateService();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getInstalledPlugins', () => {
it('parses installed_plugins.json version 2 format', async () => {
const installedData = {
version: 2,
plugins: {
'context7@claude-plugins-official': [
{
scope: 'user',
installPath: '/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0',
version: '1.0.0',
installedAt: '2026-03-01T11:14:21.926Z',
},
],
'typescript-lsp@claude-plugins-official': [
{
scope: 'user',
version: '1.0.0',
installedAt: '2026-03-02T10:00:00.000Z',
},
{
scope: 'project',
version: '1.0.0',
installedAt: '2026-03-03T10:00:00.000Z',
},
],
},
};
mockedFs.readFile.mockResolvedValue(JSON.stringify(installedData));
const entries = await service.getInstalledPlugins();
expect(entries).toHaveLength(3);
expect(entries[0].pluginId).toBe('context7@claude-plugins-official');
expect(entries[0].scope).toBe('user');
expect(entries[0].version).toBe('1.0.0');
expect(entries[1].pluginId).toBe('typescript-lsp@claude-plugins-official');
expect(entries[1].scope).toBe('user');
expect(entries[2].pluginId).toBe('typescript-lsp@claude-plugins-official');
expect(entries[2].scope).toBe('project');
});
it('returns empty array when file does not exist', async () => {
const enoent = new Error('ENOENT') as NodeJS.ErrnoException;
enoent.code = 'ENOENT';
mockedFs.readFile.mockRejectedValue(enoent);
const entries = await service.getInstalledPlugins();
expect(entries).toEqual([]);
});
it('returns empty array for unexpected version', async () => {
mockedFs.readFile.mockResolvedValue(JSON.stringify({ version: 1, plugins: {} }));
const entries = await service.getInstalledPlugins();
expect(entries).toEqual([]);
});
it('caches within TTL', async () => {
mockedFs.readFile.mockResolvedValue(
JSON.stringify({ version: 2, plugins: {} }),
);
await service.getInstalledPlugins();
await service.getInstalledPlugins();
// Only one read
expect(mockedFs.readFile).toHaveBeenCalledTimes(1);
});
});
describe('getInstallCounts', () => {
it('parses install-counts-cache.json', async () => {
const countsData = {
version: 1,
fetchedAt: '2026-03-06T18:17:44.050Z',
counts: [
{ plugin: 'frontend-design@claude-plugins-official', unique_installs: 277472 },
{ plugin: 'context7@claude-plugins-official', unique_installs: 150681 },
],
};
mockedFs.readFile.mockResolvedValue(JSON.stringify(countsData));
const counts = await service.getInstallCounts();
expect(counts.get('frontend-design@claude-plugins-official')).toBe(277472);
expect(counts.get('context7@claude-plugins-official')).toBe(150681);
expect(counts.get('nonexistent')).toBeUndefined();
});
it('returns empty map when file does not exist', async () => {
const enoent = new Error('ENOENT') as NodeJS.ErrnoException;
enoent.code = 'ENOENT';
mockedFs.readFile.mockRejectedValue(enoent);
const counts = await service.getInstallCounts();
expect(counts.size).toBe(0);
});
it('caches within TTL', async () => {
mockedFs.readFile.mockResolvedValue(
JSON.stringify({ version: 1, counts: [] }),
);
await service.getInstallCounts();
await service.getInstallCounts();
expect(mockedFs.readFile).toHaveBeenCalledTimes(1);
});
});
describe('invalidateCache', () => {
it('forces re-read after invalidation', async () => {
mockedFs.readFile.mockResolvedValue(
JSON.stringify({ version: 2, plugins: {} }),
);
await service.getInstalledPlugins();
service.invalidateCache();
await service.getInstalledPlugins();
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -0,0 +1,273 @@
/**
* Tests for extensionsSlice global catalog caches.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTestStore, type TestStore } from './storeTestUtils';
// Mock the renderer api module
vi.mock('../../../src/renderer/api', () => ({
api: {
plugins: {
getAll: vi.fn(),
getReadme: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
},
mcpRegistry: {
search: vi.fn(),
browse: vi.fn(),
getById: vi.fn(),
getInstalled: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
},
},
}));
import { api } from '../../../src/renderer/api';
import type { EnrichedPlugin, McpCatalogItem } from '../../../src/shared/types/extensions';
const makePlugin = (overrides: Partial<EnrichedPlugin>): EnrichedPlugin => ({
pluginId: 'test@marketplace',
marketplaceId: 'test@marketplace',
qualifiedName: 'test@marketplace',
name: 'Test Plugin',
description: 'A test plugin',
category: 'testing',
hasLspServers: false,
hasMcpServers: false,
hasAgents: false,
hasCommands: false,
hasHooks: false,
isExternal: false,
installCount: 100,
isInstalled: false,
installations: [],
...overrides,
});
const makeMcpServer = (overrides: Partial<McpCatalogItem>): McpCatalogItem => ({
id: 'test-server',
name: 'Test Server',
description: 'A test MCP server',
source: 'official',
installSpec: null,
envVars: [],
tools: [],
requiresAuth: false,
...overrides,
});
describe('extensionsSlice', () => {
let store: TestStore;
beforeEach(() => {
store = createTestStore();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('fetchPluginCatalog', () => {
it('fetches and stores plugins', async () => {
const plugins = [makePlugin({ pluginId: 'a@m' }), makePlugin({ pluginId: 'b@m' })];
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
await store.getState().fetchPluginCatalog();
expect(store.getState().pluginCatalog).toHaveLength(2);
expect(store.getState().pluginCatalogLoading).toBe(false);
expect(store.getState().pluginCatalogError).toBeNull();
});
it('sets error on failure', async () => {
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
await store.getState().fetchPluginCatalog();
expect(store.getState().pluginCatalog).toEqual([]);
expect(store.getState().pluginCatalogError).toBe('boom');
expect(store.getState().pluginCatalogLoading).toBe(false);
});
});
describe('fetchPluginReadme', () => {
it('fetches and caches README', async () => {
(api.plugins!.getReadme as ReturnType<typeof vi.fn>).mockResolvedValue('# Hello');
store.getState().fetchPluginReadme('test@m');
// Wait for the async to resolve
await vi.waitFor(() => {
expect(store.getState().pluginReadmes['test@m']).toBe('# Hello');
});
expect(store.getState().pluginReadmeLoading['test@m']).toBe(false);
});
it('does not re-fetch cached README', () => {
store.setState({ pluginReadmes: { 'test@m': 'cached' } });
store.getState().fetchPluginReadme('test@m');
expect(api.plugins!.getReadme).not.toHaveBeenCalled();
});
});
describe('mcpBrowse', () => {
it('fetches initial browse results', async () => {
const servers = [makeMcpServer({ id: 's1' }), makeMcpServer({ id: 's2' })];
(api.mcpRegistry!.browse as ReturnType<typeof vi.fn>).mockResolvedValue({
servers,
nextCursor: 'cursor-abc',
});
await store.getState().mcpBrowse();
expect(store.getState().mcpBrowseCatalog).toHaveLength(2);
expect(store.getState().mcpBrowseNextCursor).toBe('cursor-abc');
expect(store.getState().mcpBrowseLoading).toBe(false);
});
it('appends on cursor-based pagination', async () => {
store.setState({ mcpBrowseCatalog: [makeMcpServer({ id: 'existing' })] });
const newServers = [makeMcpServer({ id: 'new1' })];
(api.mcpRegistry!.browse as ReturnType<typeof vi.fn>).mockResolvedValue({
servers: newServers,
nextCursor: undefined,
});
await store.getState().mcpBrowse('cursor-1');
expect(store.getState().mcpBrowseCatalog).toHaveLength(2);
expect(store.getState().mcpBrowseNextCursor).toBeUndefined();
});
it('sets error on failure', async () => {
(api.mcpRegistry!.browse as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('fail'));
await store.getState().mcpBrowse();
expect(store.getState().mcpBrowseError).toBe('fail');
expect(store.getState().mcpBrowseLoading).toBe(false);
});
});
describe('mcpFetchInstalled', () => {
it('fetches installed MCP servers', async () => {
const installed = [{ name: 'server-a', scope: 'user' as const }];
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue(installed);
await store.getState().mcpFetchInstalled();
expect(store.getState().mcpInstalledServers).toEqual(installed);
});
});
describe('openExtensionsTab', () => {
it('opens a new extensions tab', () => {
// Ensure we have a focused pane
expect(store.getState().paneLayout.panes.length).toBeGreaterThan(0);
store.getState().openExtensionsTab();
const tabs = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
const extTab = tabs.find((t) => t.type === 'extensions');
expect(extTab).toBeDefined();
expect(extTab!.label).toBe('Extensions');
});
it('activates existing extensions tab instead of creating new', () => {
store.getState().openExtensionsTab();
const tabs1 = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
const count1 = tabs1.filter((t) => t.type === 'extensions').length;
store.getState().openExtensionsTab();
const tabs2 = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
const count2 = tabs2.filter((t) => t.type === 'extensions').length;
expect(count1).toBe(1);
expect(count2).toBe(1); // no duplicate
});
});
describe('installPlugin', () => {
it('sets progress to pending then success', async () => {
const plugins = [makePlugin({ pluginId: 'a@m' })];
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
// During execution, should be pending
expect(store.getState().pluginInstallProgress['test@m']).toBe('pending');
await promise;
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
});
it('sets progress to error on failure', async () => {
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
state: 'error',
error: 'Not found',
});
await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' });
expect(store.getState().pluginInstallProgress['fail@m']).toBe('error');
});
});
describe('uninstallPlugin', () => {
it('sets progress to pending then success', async () => {
const plugins = [makePlugin({ pluginId: 'a@m', isInstalled: false })];
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
const promise = store.getState().uninstallPlugin('test@m', 'user');
expect(store.getState().pluginInstallProgress['test@m']).toBe('pending');
await promise;
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
});
});
describe('installMcpServer', () => {
it('sets progress to pending then success', async () => {
(api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const promise = store.getState().installMcpServer({
registryId: 'test-id',
serverName: 'test-server',
scope: 'user',
envValues: {},
headers: [],
});
expect(store.getState().mcpInstallProgress['test-id']).toBe('pending');
await promise;
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
});
});
describe('uninstallMcpServer', () => {
it('sets progress to pending then success', async () => {
(api.mcpRegistry!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const promise = store.getState().uninstallMcpServer('test-server', 'user');
expect(store.getState().mcpInstallProgress['test-server']).toBe('pending');
await promise;
expect(store.getState().mcpInstallProgress['test-server']).toBe('success');
});
});
});

View file

@ -10,6 +10,7 @@ import { createConfigSlice } from '../../../src/renderer/store/slices/configSlic
import { createConnectionSlice } from '../../../src/renderer/store/slices/connectionSlice'; import { createConnectionSlice } from '../../../src/renderer/store/slices/connectionSlice';
import { createContextSlice } from '../../../src/renderer/store/slices/contextSlice'; import { createContextSlice } from '../../../src/renderer/store/slices/contextSlice';
import { createEditorSlice } from '../../../src/renderer/store/slices/editorSlice'; import { createEditorSlice } from '../../../src/renderer/store/slices/editorSlice';
import { createExtensionsSlice } from '../../../src/renderer/store/slices/extensionsSlice';
import { createConversationSlice } from '../../../src/renderer/store/slices/conversationSlice'; import { createConversationSlice } from '../../../src/renderer/store/slices/conversationSlice';
import { createNotificationSlice } from '../../../src/renderer/store/slices/notificationSlice'; import { createNotificationSlice } from '../../../src/renderer/store/slices/notificationSlice';
import { createPaneSlice } from '../../../src/renderer/store/slices/paneSlice'; import { createPaneSlice } from '../../../src/renderer/store/slices/paneSlice';
@ -51,6 +52,7 @@ export function createTestStore() {
...createChangeReviewSlice(...args), ...createChangeReviewSlice(...args),
...createCliInstallerSlice(...args), ...createCliInstallerSlice(...args),
...createEditorSlice(...args), ...createEditorSlice(...args),
...createExtensionsSlice(...args),
})); }));
} }

View file

@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import type { PluginCatalogItem } from '@shared/types/extensions';
import {
buildPluginId,
formatInstallCount,
getCapabilityLabel,
getPrimaryCapabilityLabel,
inferCapabilities,
normalizeCategory,
normalizeRepoUrl,
} from '@shared/utils/extensionNormalizers';
describe('normalizeRepoUrl', () => {
it('lowercases and strips .git', () => {
expect(normalizeRepoUrl('https://GitHub.com/Org/Repo.git')).toBe(
'https://github.com/org/repo',
);
});
it('strips trailing slashes', () => {
expect(normalizeRepoUrl('https://github.com/org/repo/')).toBe(
'https://github.com/org/repo',
);
});
it('handles already clean URLs', () => {
expect(normalizeRepoUrl('https://github.com/org/repo')).toBe(
'https://github.com/org/repo',
);
});
});
describe('inferCapabilities', () => {
const makePlugin = (overrides: Partial<PluginCatalogItem>): PluginCatalogItem => ({
pluginId: 'test@marketplace',
marketplaceId: 'test@marketplace',
qualifiedName: 'test@marketplace',
name: 'test',
description: 'test',
category: 'development',
hasLspServers: false,
hasMcpServers: false,
hasAgents: false,
hasCommands: false,
hasHooks: false,
isExternal: false,
...overrides,
});
it('returns "skill" fallback when no capabilities', () => {
expect(inferCapabilities(makePlugin({}))).toEqual(['skill']);
});
it('detects LSP capability', () => {
expect(inferCapabilities(makePlugin({ hasLspServers: true }))).toEqual(['lsp']);
});
it('detects multiple capabilities', () => {
expect(
inferCapabilities(makePlugin({ hasLspServers: true, hasMcpServers: true })),
).toEqual(['lsp', 'mcp']);
});
it('preserves capability order', () => {
expect(
inferCapabilities(
makePlugin({
hasHooks: true,
hasAgents: true,
hasLspServers: true,
}),
),
).toEqual(['lsp', 'agent', 'hook']);
});
});
describe('getPrimaryCapabilityLabel', () => {
it('returns "Skill" for empty array', () => {
expect(getPrimaryCapabilityLabel([])).toBe('Skill');
});
it('returns label for first capability', () => {
expect(getPrimaryCapabilityLabel(['lsp', 'mcp'])).toBe('LSP');
});
});
describe('getCapabilityLabel', () => {
it('maps all capabilities', () => {
expect(getCapabilityLabel('lsp')).toBe('LSP');
expect(getCapabilityLabel('mcp')).toBe('MCP');
expect(getCapabilityLabel('agent')).toBe('Agent');
expect(getCapabilityLabel('command')).toBe('Command');
expect(getCapabilityLabel('hook')).toBe('Hook');
expect(getCapabilityLabel('skill')).toBe('Skill');
});
});
describe('formatInstallCount', () => {
it('formats small numbers as-is', () => {
expect(formatInstallCount(0)).toBe('0');
expect(formatInstallCount(42)).toBe('42');
expect(formatInstallCount(999)).toBe('999');
});
it('formats thousands with K suffix', () => {
expect(formatInstallCount(1_000)).toBe('1K');
expect(formatInstallCount(1_500)).toBe('1.5K');
expect(formatInstallCount(10_000)).toBe('10K');
expect(formatInstallCount(277_472)).toBe('277K');
});
it('formats millions with M suffix', () => {
expect(formatInstallCount(1_000_000)).toBe('1M');
expect(formatInstallCount(1_200_000)).toBe('1.2M');
expect(formatInstallCount(15_000_000)).toBe('15M');
});
it('removes trailing .0 in formatted numbers', () => {
expect(formatInstallCount(5_000)).toBe('5K');
expect(formatInstallCount(2_000_000)).toBe('2M');
});
});
describe('normalizeCategory', () => {
it('lowercases and trims', () => {
expect(normalizeCategory(' Development ')).toBe('development');
});
it('returns "other" for undefined', () => {
expect(normalizeCategory(undefined)).toBe('other');
});
it('returns "other" for empty string', () => {
expect(normalizeCategory('')).toBe('other');
expect(normalizeCategory(' ')).toBe('other');
});
});
describe('buildPluginId', () => {
it('creates qualifiedName format', () => {
expect(buildPluginId('context7', 'claude-plugins-official')).toBe(
'context7@claude-plugins-official',
);
});
});