diff --git a/CLAUDE.md b/CLAUDE.md index 30b1de5d..0ea1f3ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,55 @@ const projectChats = db.select().from(chats).where(eq(chats.projectId, id)).all( - Session resume via `sessionId` stored in SubChat - Message streaming via tRPC subscription (`claude.onMessage`) +### Plugin Support + +1Code loads commands, skills, agents, and MCP servers from three sources (in priority order): + +| Source | Location | Priority | +|--------|----------|----------| +| Project | `.claude/commands/`, `.claude/skills/`, `.claude/agents/` | Highest | +| User | `~/.claude/commands/`, `~/.claude/skills/`, `~/.claude/agents/` | Medium | +| Plugin | `~/.claude/plugins/marketplaces/*/` | Lowest | + +**Plugin Components:** + +| Component | Plugin Location | +|-----------|-----------------| +| Commands | `commands/` | +| Skills | `skills/` | +| Agents | `agents/` | +| MCP Servers | `.mcp.json` | + +**Plugin Discovery:** Plugins installed via `claude /plugin install ` are automatically discovered from `~/.claude/plugins/marketplaces/`. Each plugin can provide commands, skills, agents, and MCP servers. + +**MCP Servers:** Plugins can define MCP servers in a `.mcp.json` file at the plugin root: +```json +{ + "mcpServers": { + "context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp@latest"] }, + "sequential-thinking": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] } + } +} +``` + +Plugin MCP servers appear in Settings → MCP as a separate group and are automatically available to Claude during chat sessions. + +**Disabling Plugins:** Add plugin identifiers to `~/.claude/settings.json`: +```json +{ + "disabledPlugins": ["ccsetup:ccsetup"] +} +``` + +Disabled plugins' commands, skills, agents, and MCP servers will not appear in the UI or be loaded. + +**Related Files:** +- `src/main/lib/plugins/index.ts` - Plugin discovery utility (including MCP discovery) +- `src/main/lib/trpc/routers/commands.ts` - Commands loading with plugin support +- `src/main/lib/trpc/routers/skills.ts` - Skills loading with plugin support +- `src/main/lib/trpc/routers/agents.ts` - Agents loading with plugin support +- `src/main/lib/trpc/routers/claude.ts` - MCP server integration with plugin support + ## Tech Stack | Layer | Tech | diff --git a/src/main/lib/claude-config.ts b/src/main/lib/claude-config.ts index 74ac115f..8bf5c08c 100644 --- a/src/main/lib/claude-config.ts +++ b/src/main/lib/claude-config.ts @@ -149,6 +149,38 @@ export function updateMcpServerConfig( return config } +/** + * Remove an MCP server from config + * Use projectPath = GLOBAL_MCP_PATH (or null) for global MCP servers + * Automatically resolves worktree paths to original project paths + */ +export function removeMcpServerConfig( + config: ClaudeConfig, + projectPath: string | null, + serverName: string +): ClaudeConfig { + // Global MCP servers + if (!projectPath || projectPath === GLOBAL_MCP_PATH) { + if (config.mcpServers?.[serverName]) { + delete config.mcpServers[serverName] + } + return config + } + // Project-specific MCP servers + const resolvedPath = resolveProjectPathFromWorktree(projectPath) || projectPath + if (config.projects?.[resolvedPath]?.mcpServers?.[serverName]) { + delete config.projects[resolvedPath].mcpServers[serverName] + // Clean up empty objects + if (Object.keys(config.projects[resolvedPath].mcpServers).length === 0) { + delete config.projects[resolvedPath].mcpServers + } + if (Object.keys(config.projects[resolvedPath]).length === 0) { + delete config.projects[resolvedPath] + } + } + return config +} + /** * Resolve original project path from a worktree path. * Supports legacy (~/.21st/worktrees/{projectId}/{chatId}/) and diff --git a/src/main/lib/plugins/index.ts b/src/main/lib/plugins/index.ts new file mode 100644 index 00000000..ea2b4bc8 --- /dev/null +++ b/src/main/lib/plugins/index.ts @@ -0,0 +1,193 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" +import type { McpServerConfig } from "../claude-config" + +export interface PluginInfo { + name: string + version: string + path: string + source: string // e.g., "marketplace:plugin-name" +} + +interface MarketplacePlugin { + name: string + version: string + source: string +} + +interface MarketplaceJson { + name: string + plugins: MarketplacePlugin[] +} + +export interface PluginMcpConfig { + pluginSource: string // e.g., "ccsetup:ccsetup" + mcpServers: Record +} + +// Cache for plugin discovery results +let pluginCache: { plugins: PluginInfo[]; timestamp: number } | null = null +let mcpCache: { configs: PluginMcpConfig[]; timestamp: number } | null = null +const CACHE_TTL_MS = 30000 // 30 seconds - plugins don't change often during a session + +/** + * Clear plugin caches (for testing/manual invalidation) + */ +export function clearPluginCache() { + pluginCache = null + mcpCache = null +} + +/** + * Discover all installed plugins from ~/.claude/plugins/marketplaces/ + * Returns array of plugin info with paths to their component directories + * Results are cached for 30 seconds to avoid repeated filesystem scans + */ +export async function discoverInstalledPlugins(): Promise { + // Return cached result if still valid + if (pluginCache && Date.now() - pluginCache.timestamp < CACHE_TTL_MS) { + return pluginCache.plugins + } + + const plugins: PluginInfo[] = [] + const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces") + + try { + await fs.access(marketplacesDir) + } catch { + // No plugins directory exists + pluginCache = { plugins, timestamp: Date.now() } + return plugins + } + + let marketplaces: fs.Dirent[] + try { + marketplaces = await fs.readdir(marketplacesDir, { withFileTypes: true }) + } catch { + pluginCache = { plugins, timestamp: Date.now() } + return plugins + } + + for (const marketplace of marketplaces) { + if (!marketplace.isDirectory() || marketplace.name.startsWith(".")) continue + + const marketplacePath = path.join(marketplacesDir, marketplace.name) + const marketplaceJsonPath = path.join(marketplacePath, ".claude-plugin", "marketplace.json") + + try { + const content = await fs.readFile(marketplaceJsonPath, "utf-8") + + let marketplaceJson: MarketplaceJson + try { + marketplaceJson = JSON.parse(content) + } catch { + console.warn(`[plugins] Invalid JSON in ${marketplaceJsonPath}`) + continue + } + + // Validate plugins array exists + if (!Array.isArray(marketplaceJson.plugins)) { + console.warn(`[plugins] Missing plugins array in ${marketplaceJsonPath}`) + continue + } + + for (const plugin of marketplaceJson.plugins) { + // Validate plugin.source exists + if (!plugin.source) { + console.debug(`[plugins] Skipped plugin without source in ${marketplaceJsonPath}`) + continue + } + + const pluginPath = path.resolve(marketplacePath, plugin.source) + try { + await fs.access(pluginPath) + plugins.push({ + name: plugin.name, + version: plugin.version, + path: pluginPath, + source: `${marketplaceJson.name}:${plugin.name}`, + }) + } catch { + console.debug(`[plugins] Skipped plugin "${plugin.name}" - directory not found: ${pluginPath}`) + } + } + } catch { + // No marketplace.json, skip silently (expected for non-plugin directories) + } + } + + // Cache the result + pluginCache = { plugins, timestamp: Date.now() } + return plugins +} + +/** + * Get component paths for a plugin (commands, skills, agents directories) + */ +export function getPluginComponentPaths(plugin: PluginInfo) { + return { + commands: path.join(plugin.path, "commands"), + skills: path.join(plugin.path, "skills"), + agents: path.join(plugin.path, "agents"), + } +} + +/** + * Discover MCP server configs from all installed plugins + * Reads .mcp.json from each plugin directory + * Results are cached for 30 seconds to avoid repeated filesystem scans + */ +export async function discoverPluginMcpServers(): Promise { + // Return cached result if still valid + if (mcpCache && Date.now() - mcpCache.timestamp < CACHE_TTL_MS) { + return mcpCache.configs + } + + const plugins = await discoverInstalledPlugins() + const configs: PluginMcpConfig[] = [] + + for (const plugin of plugins) { + const mcpJsonPath = path.join(plugin.path, ".mcp.json") + try { + const content = await fs.readFile(mcpJsonPath, "utf-8") + let mcpConfig: { mcpServers?: unknown } + try { + mcpConfig = JSON.parse(content) + } catch { + console.warn(`[plugins] Invalid JSON in ${mcpJsonPath}`) + continue + } + + // Validate mcpServers is an object with valid server configs + if ( + mcpConfig.mcpServers && + typeof mcpConfig.mcpServers === "object" && + !Array.isArray(mcpConfig.mcpServers) + ) { + // Filter to only include valid server config objects + const validServers: Record = {} + for (const [name, config] of Object.entries(mcpConfig.mcpServers)) { + if (config && typeof config === "object" && !Array.isArray(config)) { + validServers[name] = config as McpServerConfig + } else { + console.debug(`[plugins] Skipped invalid MCP server config "${name}" in ${mcpJsonPath}`) + } + } + + if (Object.keys(validServers).length > 0) { + configs.push({ + pluginSource: plugin.source, + mcpServers: validServers, + }) + } + } + } catch { + // No .mcp.json file, skip silently (this is expected for most plugins) + } + } + + // Cache the result + mcpCache = { configs, timestamp: Date.now() } + return configs +} diff --git a/src/main/lib/trpc/routers/agent-utils.ts b/src/main/lib/trpc/routers/agent-utils.ts index b8090163..8dbd72e1 100644 --- a/src/main/lib/trpc/routers/agent-utils.ts +++ b/src/main/lib/trpc/routers/agent-utils.ts @@ -2,6 +2,8 @@ import * as fs from "fs/promises" import * as path from "path" import * as os from "os" import matter from "gray-matter" +import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" +import { getDisabledPlugins } from "./claude-settings" // Valid model values for agents export const VALID_AGENT_MODELS = ["sonnet", "opus", "haiku", "inherit"] as const @@ -19,7 +21,8 @@ export interface ParsedAgent { // Agent with source/path metadata export interface FileAgent extends ParsedAgent { - source: "user" | "project" + source: "user" | "project" | "plugin" + pluginName?: string path: string } @@ -114,12 +117,13 @@ export function generateAgentMd(agent: { /** * Load agent definition from filesystem by name - * Searches in user (~/.claude/agents/) and project (.claude/agents/) directories + * Searches in user (~/.claude/agents/), project (.claude/agents/), and plugin directories */ export async function loadAgent( name: string, cwd?: string ): Promise { + // Search user and project directories first const locations = [ path.join(os.homedir(), ".claude", "agents"), ...(cwd ? [path.join(cwd, ".claude", "agents")] : []), @@ -146,7 +150,44 @@ export async function loadAgent( } } - return null + // Search in plugin directories in parallel (respecting disabled plugins) + const [disabledPlugins, installedPlugins] = await Promise.all([ + getDisabledPlugins(), + discoverInstalledPlugins(), + ]) + + // Filter out disabled plugins + const enabledPlugins = installedPlugins.filter( + (p) => !disabledPlugins.includes(p.source) + ) + + // Search all plugins in parallel and return first valid match + const pluginResults = await Promise.all( + enabledPlugins.map(async (plugin) => { + const paths = getPluginComponentPaths(plugin) + const agentPath = path.join(paths.agents, `${name}.md`) + try { + const content = await fs.readFile(agentPath, "utf-8") + const parsed = parseAgentMd(content, `${name}.md`) + + if (parsed.description && parsed.prompt) { + return { + name: parsed.name || name, + description: parsed.description, + prompt: parsed.prompt, + tools: parsed.tools, + disallowedTools: parsed.disallowedTools, + model: parsed.model, + } + } + } catch { + // Agent not found in this plugin + } + return null + }) + ) + + return pluginResults.find((r) => r !== null) ?? null } /** @@ -155,7 +196,7 @@ export async function loadAgent( */ export async function scanAgentsDirectory( dir: string, - source: "user" | "project", + source: "user" | "project" | "plugin", basePath?: string // For project agents, the cwd to make paths relative to ): Promise { const agents: FileAgent[] = [] diff --git a/src/main/lib/trpc/routers/agents.ts b/src/main/lib/trpc/routers/agents.ts index 2416f5b0..3e15501a 100644 --- a/src/main/lib/trpc/routers/agents.ts +++ b/src/main/lib/trpc/routers/agents.ts @@ -10,6 +10,8 @@ import { VALID_AGENT_MODELS, type FileAgent, } from "./agent-utils" +import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" +import { getDisabledPlugins } from "./claude-settings" // Shared procedure for listing agents const listAgentsProcedure = publicProcedure @@ -30,12 +32,39 @@ const listAgentsProcedure = publicProcedure projectAgentsPromise = scanAgentsDirectory(projectAgentsDir, "project", input.cwd) } - const [userAgents, projectAgents] = await Promise.all([ + // Get disabled plugins and discover installed plugins + const [disabledPlugins, installedPlugins] = await Promise.all([ + getDisabledPlugins(), + discoverInstalledPlugins(), + ]) + + // Filter out disabled plugins before scanning + const enabledPlugins = installedPlugins.filter( + (p) => !disabledPlugins.includes(p.source) + ) + + // Scan enabled plugins for agents + const pluginAgentsPromises = enabledPlugins.map(async (plugin) => { + const paths = getPluginComponentPaths(plugin) + try { + const agents = await scanAgentsDirectory(paths.agents, "plugin") + return agents.map((agent) => ({ ...agent, pluginName: plugin.source })) + } catch { + return [] + } + }) + + // Scan all directories in parallel + const [userAgents, projectAgents, ...pluginAgentsArrays] = await Promise.all([ userAgentsPromise, projectAgentsPromise, + ...pluginAgentsPromises, ]) - return [...projectAgents, ...userAgents] + const pluginAgents = pluginAgentsArrays.flat() + + // Priority: project > user > plugin + return [...projectAgents, ...userAgents, ...pluginAgents] }) export const agentsRouter = router({ @@ -57,10 +86,11 @@ export const agentsRouter = router({ get: publicProcedure .input(z.object({ name: z.string(), cwd: z.string().optional() })) .query(async ({ input }) => { - const locations = [ + // Search user and project directories first + const locations: { dir: string; source: "user" | "project" }[] = [ { dir: path.join(os.homedir(), ".claude", "agents"), - source: "user" as const, + source: "user", }, ...(input.cwd ? [ @@ -86,7 +116,39 @@ export const agentsRouter = router({ continue } } - return null + + // Search in plugin directories in parallel (respecting disabled plugins) + const [disabledPlugins, installedPlugins] = await Promise.all([ + getDisabledPlugins(), + discoverInstalledPlugins(), + ]) + + const enabledPlugins = installedPlugins.filter( + (p) => !disabledPlugins.includes(p.source) + ) + + // Search all plugins in parallel and return first match + const pluginResults = await Promise.all( + enabledPlugins.map(async (plugin) => { + const paths = getPluginComponentPaths(plugin) + const agentPath = path.join(paths.agents, `${input.name}.md`) + try { + const content = await fs.readFile(agentPath, "utf-8") + const parsed = parseAgentMd(content, `${input.name}.md`) + return { + ...parsed, + source: "plugin" as const, + pluginName: plugin.source, + path: agentPath, + } + } catch { + return null + } + }) + ) + + // Return first found plugin agent + return pluginResults.find((r) => r !== null) ?? null }), /** diff --git a/src/main/lib/trpc/routers/claude-settings.ts b/src/main/lib/trpc/routers/claude-settings.ts index aa23235a..e4151f19 100644 --- a/src/main/lib/trpc/routers/claude-settings.ts +++ b/src/main/lib/trpc/routers/claude-settings.ts @@ -6,6 +6,30 @@ import { router, publicProcedure } from "../index" const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json") +// Cache for disabled plugins to avoid repeated filesystem reads +let disabledPluginsCache: { plugins: string[]; timestamp: number } | null = null +const DISABLED_PLUGINS_CACHE_TTL_MS = 5000 // 5 seconds + +// Cache for approved plugin MCP servers +let approvedMcpCache: { servers: string[]; timestamp: number } | null = null +const APPROVED_MCP_CACHE_TTL_MS = 5000 // 5 seconds + +/** + * Invalidate the disabled plugins cache + * Call this when disabledPlugins setting changes + */ +export function invalidateDisabledPluginsCache(): void { + disabledPluginsCache = null +} + +/** + * Invalidate the approved MCP servers cache + * Call this when approvedPluginMcpServers setting changes + */ +export function invalidateApprovedMcpCache(): void { + approvedMcpCache = null +} + /** * Read Claude settings.json file * Returns empty object if file doesn't exist @@ -20,6 +44,54 @@ async function readClaudeSettings(): Promise> { } } +/** + * Get list of disabled plugin identifiers from settings.json + * Returns empty array if no disabled plugins + * Results are cached for 5 seconds to reduce filesystem reads + */ +export async function getDisabledPlugins(): Promise { + // Return cached result if still valid + if (disabledPluginsCache && Date.now() - disabledPluginsCache.timestamp < DISABLED_PLUGINS_CACHE_TTL_MS) { + return disabledPluginsCache.plugins + } + + const settings = await readClaudeSettings() + const plugins = Array.isArray(settings.disabledPlugins) ? settings.disabledPlugins as string[] : [] + + disabledPluginsCache = { plugins, timestamp: Date.now() } + return plugins +} + +/** + * Get list of approved plugin MCP server identifiers from settings.json + * Format: "{pluginSource}:{serverName}" e.g., "ccsetup:ccsetup:context7" + * Returns empty array if no approved servers + * Results are cached for 5 seconds to reduce filesystem reads + */ +export async function getApprovedPluginMcpServers(): Promise { + // Return cached result if still valid + if (approvedMcpCache && Date.now() - approvedMcpCache.timestamp < APPROVED_MCP_CACHE_TTL_MS) { + return approvedMcpCache.servers + } + + const settings = await readClaudeSettings() + const servers = Array.isArray(settings.approvedPluginMcpServers) + ? settings.approvedPluginMcpServers as string[] + : [] + + approvedMcpCache = { servers, timestamp: Date.now() } + return servers +} + +/** + * Check if a plugin MCP server is approved + */ +export async function isPluginMcpApproved(pluginSource: string, serverName: string): Promise { + const approved = await getApprovedPluginMcpServers() + const identifier = `${pluginSource}:${serverName}` + return approved.includes(identifier) +} + /** * Write Claude settings.json file * Creates the .claude directory if it doesn't exist @@ -61,4 +133,120 @@ export const claudeSettingsRouter = router({ await writeClaudeSettings(settings) return { success: true } }), + + /** + * Get list of disabled plugins + */ + getDisabledPlugins: publicProcedure.query(async () => { + return await getDisabledPlugins() + }), + + /** + * Set a plugin's disabled state + */ + setPluginDisabled: publicProcedure + .input( + z.object({ + pluginSource: z.string(), + disabled: z.boolean(), + }) + ) + .mutation(async ({ input }) => { + const settings = await readClaudeSettings() + const disabledPlugins = Array.isArray(settings.disabledPlugins) + ? (settings.disabledPlugins as string[]) + : [] + + if (input.disabled && !disabledPlugins.includes(input.pluginSource)) { + disabledPlugins.push(input.pluginSource) + } else if (!input.disabled) { + const index = disabledPlugins.indexOf(input.pluginSource) + if (index > -1) disabledPlugins.splice(index, 1) + } + + settings.disabledPlugins = disabledPlugins + await writeClaudeSettings(settings) + invalidateDisabledPluginsCache() + return { success: true } + }), + + /** + * Get list of approved plugin MCP servers + */ + getApprovedPluginMcpServers: publicProcedure.query(async () => { + return await getApprovedPluginMcpServers() + }), + + /** + * Approve a plugin MCP server + * Identifier format: "{pluginSource}:{serverName}" + */ + approvePluginMcpServer: publicProcedure + .input(z.object({ identifier: z.string() })) + .mutation(async ({ input }) => { + const settings = await readClaudeSettings() + const approved = Array.isArray(settings.approvedPluginMcpServers) + ? (settings.approvedPluginMcpServers as string[]) + : [] + + if (!approved.includes(input.identifier)) { + approved.push(input.identifier) + } + + settings.approvedPluginMcpServers = approved + await writeClaudeSettings(settings) + invalidateApprovedMcpCache() + return { success: true } + }), + + /** + * Revoke approval for a plugin MCP server + * Identifier format: "{pluginSource}:{serverName}" + */ + revokePluginMcpServer: publicProcedure + .input(z.object({ identifier: z.string() })) + .mutation(async ({ input }) => { + const settings = await readClaudeSettings() + const approved = Array.isArray(settings.approvedPluginMcpServers) + ? (settings.approvedPluginMcpServers as string[]) + : [] + + const index = approved.indexOf(input.identifier) + if (index > -1) { + approved.splice(index, 1) + } + + settings.approvedPluginMcpServers = approved + await writeClaudeSettings(settings) + invalidateApprovedMcpCache() + return { success: true } + }), + + /** + * Approve all MCP servers from a plugin + * Takes the pluginSource (e.g., "ccsetup:ccsetup") and list of server names + */ + approveAllPluginMcpServers: publicProcedure + .input(z.object({ + pluginSource: z.string(), + serverNames: z.array(z.string()), + })) + .mutation(async ({ input }) => { + const settings = await readClaudeSettings() + const approved = Array.isArray(settings.approvedPluginMcpServers) + ? (settings.approvedPluginMcpServers as string[]) + : [] + + for (const serverName of input.serverNames) { + const identifier = `${input.pluginSource}:${serverName}` + if (!approved.includes(identifier)) { + approved.push(identifier) + } + } + + settings.approvedPluginMcpServers = approved + await writeClaudeSettings(settings) + invalidateApprovedMcpCache() + return { success: true } + }), }) diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index d495c1df..979eb3eb 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -15,7 +15,7 @@ import { logRawClaudeMessage, type UIMessageChunk, } from "../../claude" -import { getProjectMcpServers, GLOBAL_MCP_PATH, readClaudeConfig, type McpServerConfig } from "../../claude-config" +import { getProjectMcpServers, GLOBAL_MCP_PATH, readClaudeConfig, writeClaudeConfig, updateMcpServerConfig, removeMcpServerConfig, type McpServerConfig } from "../../claude-config" import { chats, claudeCodeCredentials, getDatabase, subChats } from "../../db" import { createRollbackStash } from "../../git/stash" import { ensureMcpTokensFresh, fetchMcpTools, fetchMcpToolsStdio, getMcpAuthStatus, startMcpOAuth } from "../../mcp-auth" @@ -23,6 +23,8 @@ import { fetchOAuthMetadata, getMcpBaseUrl } from "../../oauth" import { setConnectionMethod } from "../../analytics" import { publicProcedure, router } from "../index" import { buildAgentsOption } from "./agent-utils" +import { discoverPluginMcpServers } from "../../plugins" +import { getDisabledPlugins, getApprovedPluginMcpServers } from "./claude-settings" /** * Parse @[agent:name], @[skill:name], and @[tool:name] mentions from prompt text @@ -738,11 +740,36 @@ export const claudeRouter = router({ mcpConfigCache.set(claudeJsonSource, { config: claudeConfig, mtime: currentMtime }) } - // Merge global + project servers (project overrides global) + // Merge global + project + plugin servers (project > global > plugin priority) // getProjectMcpServers resolves worktree paths internally const globalServers = claudeConfig.mcpServers || {} const projectServers = getProjectMcpServers(claudeConfig, lookupPath) || {} - mcpServersForSdk = { ...globalServers, ...projectServers } + + // Load plugin MCP servers (filtered by disabled plugins and approval) + const [disabledPlugins, pluginMcpConfigs, approvedServers] = await Promise.all([ + getDisabledPlugins(), + discoverPluginMcpServers(), + getApprovedPluginMcpServers(), + ]) + + const pluginServers: Record = {} + for (const config of pluginMcpConfigs) { + if (!disabledPlugins.includes(config.pluginSource)) { + // Add plugin servers (user/project take precedence, and must be approved) + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + if (!globalServers[name] && !projectServers[name]) { + // Check if this plugin MCP server is approved + const identifier = `${config.pluginSource}:${name}` + if (approvedServers.includes(identifier)) { + pluginServers[name] = serverConfig + } + } + } + } + } + + // Priority: project > global > plugin + mcpServersForSdk = { ...pluginServers, ...globalServers, ...projectServers } } } catch (configErr) { console.error(`[claude] Failed to read MCP config:`, configErr) @@ -1942,6 +1969,89 @@ ${prompt} } } + // Plugin MCPs (from installed plugins) + const [disabledPlugins, pluginMcpConfigs, approvedServers] = await Promise.all([ + getDisabledPlugins(), + discoverPluginMcpServers(), + getApprovedPluginMcpServers(), + ]) + + for (const pluginConfig of pluginMcpConfigs) { + const isDisabled = disabledPlugins.includes(pluginConfig.pluginSource) + + if (Object.keys(pluginConfig.mcpServers).length > 0) { + const pluginMcpServers = await Promise.all( + Object.entries(pluginConfig.mcpServers).map(async ([name, serverConfig]) => { + const configObj = serverConfig as Record + + // Check approval status + const identifier = `${pluginConfig.pluginSource}:${name}` + const isApproved = approvedServers.includes(identifier) + + // If plugin is disabled, mark all servers as disabled + if (isDisabled) { + return { + name, + status: "disabled", + tools: [] as string[], + needsAuth: false, + config: configObj, + isApproved, + } + } + + // If not approved, mark as pending-approval + if (!isApproved) { + return { + name, + status: "pending-approval", + tools: [] as string[], + needsAuth: false, + config: configObj, + isApproved, + } + } + + // Otherwise, try to get status and tools + let status = getServerStatusFromConfig(serverConfig) + let tools: string[] = [] + let needsAuth = false + + try { + tools = await fetchToolsForServer(serverConfig) + if (tools.length > 0) { + status = "connected" + } + } catch { + // Failed to fetch tools + } + + if (tools.length === 0 && serverConfig.url) { + try { + const baseUrl = getMcpBaseUrl(serverConfig.url) + const metadata = await fetchOAuthMetadata(baseUrl) + needsAuth = !!metadata && !!metadata.authorization_endpoint + } catch { + // If probe fails, assume no auth needed + } + } + + if (needsAuth && !(serverConfig.headers as Record | undefined)?.Authorization) { + status = "needs-auth" + } + + return { name, status, tools, needsAuth, config: configObj, isApproved } + }) + ) + + groups.push({ + groupName: `Plugin: ${pluginConfig.pluginSource}`, + projectPath: null, + mcpServers: pluginMcpServers, + }) + } + } + return { groups } } catch (error) { console.error("[getAllMcpConfig] Error:", error) @@ -2018,4 +2128,294 @@ ${prompt} .query(async ({ input }) => { return getMcpAuthStatus(input.serverName, input.projectPath) }), + + /** + * Add a new MCP server + */ + addMcpServer: publicProcedure + .input(z.object({ + name: z.string().min(1).regex(/^[a-zA-Z0-9_-]+$/, "Name must contain only letters, numbers, underscores, and hyphens"), + scope: z.enum(["global", "project"]), + projectPath: z.string().optional(), + transport: z.enum(["stdio", "http"]), + // Stdio config + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), + // HTTP config + url: z.string().url().optional(), + authType: z.enum(["none", "oauth", "bearer"]).optional(), + bearerToken: z.string().optional(), + })) + .mutation(async ({ input }) => { + try { + // Validate transport-specific fields + if (input.transport === "stdio" && !input.command) { + return { success: false, error: "Command is required for stdio transport" } + } + if (input.transport === "http" && !input.url) { + return { success: false, error: "URL is required for HTTP transport" } + } + + const config = await readClaudeConfig() + const effectiveProjectPath = input.scope === "global" ? null : input.projectPath + + // Check if server already exists + const existing = effectiveProjectPath + ? config.projects?.[effectiveProjectPath]?.mcpServers?.[input.name] + : config.mcpServers?.[input.name] + + if (existing) { + return { success: false, error: `Server "${input.name}" already exists` } + } + + // Build server config + const serverConfig: McpServerConfig = {} + + if (input.transport === "stdio") { + serverConfig.command = input.command + if (input.args && input.args.length > 0) { + serverConfig.args = input.args + } + if (input.env && Object.keys(input.env).length > 0) { + serverConfig.env = input.env + } + } else { + serverConfig.url = input.url + serverConfig.authType = input.authType || "none" + if (input.authType === "bearer" && input.bearerToken) { + serverConfig.headers = { Authorization: `Bearer ${input.bearerToken}` } + } + } + + // Update config + const updatedConfig = updateMcpServerConfig(config, effectiveProjectPath, input.name, serverConfig) + await writeClaudeConfig(updatedConfig) + + console.log(`[addMcpServer] Added server "${input.name}" to ${input.scope} scope`) + return { success: true } + } catch (error) { + console.error("[addMcpServer] Error:", error) + return { success: false, error: error instanceof Error ? error.message : "Unknown error" } + } + }), + + /** + * Update an existing MCP server + */ + updateMcpServer: publicProcedure + .input(z.object({ + name: z.string(), + scope: z.enum(["global", "project"]), + projectPath: z.string().optional(), + // Partial updates + newName: z.string().regex(/^[a-zA-Z0-9_-]+$/).optional(), + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), + url: z.string().url().optional(), + authType: z.enum(["none", "oauth", "bearer"]).optional(), + bearerToken: z.string().optional(), + disabled: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + try { + let config = await readClaudeConfig() + const effectiveProjectPath = input.scope === "global" ? null : input.projectPath + + // Check if server exists + const existing = effectiveProjectPath + ? config.projects?.[effectiveProjectPath]?.mcpServers?.[input.name] + : config.mcpServers?.[input.name] + + if (!existing) { + return { success: false, error: `Server "${input.name}" not found` } + } + + // Handle rename + if (input.newName && input.newName !== input.name) { + // Check new name doesn't exist + const newExists = effectiveProjectPath + ? config.projects?.[effectiveProjectPath]?.mcpServers?.[input.newName] + : config.mcpServers?.[input.newName] + + if (newExists) { + return { success: false, error: `Server "${input.newName}" already exists` } + } + + // Create with new name, delete old + config = updateMcpServerConfig(config, effectiveProjectPath, input.newName, existing) + config = removeMcpServerConfig(config, effectiveProjectPath, input.name) + } + + const targetName = input.newName || input.name + + // Build update object + const update: Partial = {} + + if (input.command !== undefined) update.command = input.command + if (input.args !== undefined) update.args = input.args + if (input.env !== undefined) update.env = input.env + if (input.url !== undefined) update.url = input.url + if (input.authType !== undefined) update.authType = input.authType + if (input.disabled !== undefined) update._disabled = input.disabled + + // Handle bearer token + if (input.authType === "bearer" && input.bearerToken) { + update.headers = { Authorization: `Bearer ${input.bearerToken}` } + } else if (input.authType === "none") { + // Clear auth headers when switching to no auth + update.headers = undefined + update._oauth = undefined + } + + // Apply update + config = updateMcpServerConfig(config, effectiveProjectPath, targetName, update) + await writeClaudeConfig(config) + + console.log(`[updateMcpServer] Updated server "${targetName}" in ${input.scope} scope`) + return { success: true } + } catch (error) { + console.error("[updateMcpServer] Error:", error) + return { success: false, error: error instanceof Error ? error.message : "Unknown error" } + } + }), + + /** + * Remove an MCP server + */ + removeMcpServer: publicProcedure + .input(z.object({ + name: z.string(), + scope: z.enum(["global", "project"]), + projectPath: z.string().optional(), + })) + .mutation(async ({ input }) => { + try { + let config = await readClaudeConfig() + const effectiveProjectPath = input.scope === "global" ? null : input.projectPath + + // Check if server exists + const existing = effectiveProjectPath + ? config.projects?.[effectiveProjectPath]?.mcpServers?.[input.name] + : config.mcpServers?.[input.name] + + if (!existing) { + return { success: false, error: `Server "${input.name}" not found` } + } + + config = removeMcpServerConfig(config, effectiveProjectPath, input.name) + await writeClaudeConfig(config) + + console.log(`[removeMcpServer] Removed server "${input.name}" from ${input.scope} scope`) + return { success: true } + } catch (error) { + console.error("[removeMcpServer] Error:", error) + return { success: false, error: error instanceof Error ? error.message : "Unknown error" } + } + }), + + /** + * Set bearer token for an MCP server + */ + setMcpBearerToken: publicProcedure + .input(z.object({ + name: z.string(), + scope: z.enum(["global", "project"]), + projectPath: z.string().optional(), + token: z.string(), + })) + .mutation(async ({ input }) => { + try { + let config = await readClaudeConfig() + const effectiveProjectPath = input.scope === "global" ? null : input.projectPath + + // Check if server exists + const existing = effectiveProjectPath + ? config.projects?.[effectiveProjectPath]?.mcpServers?.[input.name] + : config.mcpServers?.[input.name] + + if (!existing) { + return { success: false, error: `Server "${input.name}" not found` } + } + + // Update with bearer token + config = updateMcpServerConfig(config, effectiveProjectPath, input.name, { + authType: "bearer", + headers: { Authorization: `Bearer ${input.token}` }, + }) + await writeClaudeConfig(config) + + console.log(`[setMcpBearerToken] Set bearer token for server "${input.name}"`) + return { success: true } + } catch (error) { + console.error("[setMcpBearerToken] Error:", error) + return { success: false, error: error instanceof Error ? error.message : "Unknown error" } + } + }), + + /** + * Get pending plugin MCP server approvals + * Returns list of plugin MCP servers that are not yet approved + */ + getPendingPluginMcpApprovals: publicProcedure + .input(z.object({ + projectPath: z.string().optional(), + })) + .query(async ({ input }) => { + try { + const [disabledPlugins, pluginMcpConfigs, approvedServers] = await Promise.all([ + getDisabledPlugins(), + discoverPluginMcpServers(), + getApprovedPluginMcpServers(), + ]) + + // Also get user/project MCP servers to check for name conflicts + const config = await readClaudeConfig() + const globalServers = config.mcpServers || {} + const projectServers = input.projectPath + ? getProjectMcpServers(config, input.projectPath) || {} + : {} + + const pendingApprovals: Array<{ + pluginSource: string + pluginName: string + serverName: string + identifier: string + command: string + args: string[] + }> = [] + + for (const pluginConfig of pluginMcpConfigs) { + // Skip disabled plugins + if (disabledPlugins.includes(pluginConfig.pluginSource)) continue + + for (const [serverName, serverConfig] of Object.entries(pluginConfig.mcpServers)) { + // Skip if user/project already has a server with this name (takes precedence) + if (globalServers[serverName] || projectServers[serverName]) continue + + const identifier = `${pluginConfig.pluginSource}:${serverName}` + + // Skip if already approved + if (approvedServers.includes(identifier)) continue + + // Add to pending approvals + const configObj = serverConfig as Record + pendingApprovals.push({ + pluginSource: pluginConfig.pluginSource, + pluginName: pluginConfig.pluginSource.split(":").pop() || pluginConfig.pluginSource, + serverName, + identifier, + command: (configObj.command as string) || "", + args: Array.isArray(configObj.args) ? configObj.args as string[] : [], + }) + } + } + + return pendingApprovals + } catch (error) { + console.error("[getPendingPluginMcpApprovals] Error:", error) + return [] + } + }), }) diff --git a/src/main/lib/trpc/routers/commands.ts b/src/main/lib/trpc/routers/commands.ts index 329a6a9a..1986c267 100644 --- a/src/main/lib/trpc/routers/commands.ts +++ b/src/main/lib/trpc/routers/commands.ts @@ -4,25 +4,30 @@ import * as fs from "fs/promises" import * as path from "path" import * as os from "os" import matter from "gray-matter" +import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" +import { getDisabledPlugins } from "./claude-settings" -interface FileCommand { +export interface FileCommand { name: string description: string argumentHint?: string - source: "user" | "project" + source: "user" | "project" | "plugin" + pluginName?: string path: string } /** - * Parse command .md frontmatter to extract description and argument-hint + * Parse command .md frontmatter to extract name, description and argument-hint */ function parseCommandMd(content: string): { + name?: string description?: string argumentHint?: string } { try { const { data } = matter(content) return { + name: typeof data.name === "string" ? data.name : undefined, description: typeof data.description === "string" ? data.description : undefined, argumentHint: @@ -49,7 +54,7 @@ function isValidEntryName(name: string): boolean { */ async function scanCommandsDirectory( dir: string, - source: "user" | "project", + source: "user" | "project" | "plugin", prefix = "", ): Promise { const commands: FileCommand[] = [] @@ -82,12 +87,15 @@ async function scanCommandsDirectory( commands.push(...nestedCommands) } else if (entry.isFile() && entry.name.endsWith(".md")) { const baseName = entry.name.replace(/\.md$/, "") - const commandName = prefix ? `${prefix}:${baseName}` : baseName + const fallbackName = prefix ? `${prefix}:${baseName}` : baseName try { const content = await fs.readFile(fullPath, "utf-8") const parsed = parseCommandMd(content) + // Use frontmatter name if available, otherwise use filename-based name + const commandName = parsed.name || fallbackName + commands.push({ name: commandName, description: parsed.description || "", @@ -138,14 +146,40 @@ export const commandsRouter = router({ ) } - // Scan both directories in parallel - const [userCommands, projectCommands] = await Promise.all([ - userCommandsPromise, - projectCommandsPromise, + // Get disabled plugins and discover installed plugins + const [disabledPlugins, installedPlugins] = await Promise.all([ + getDisabledPlugins(), + discoverInstalledPlugins(), ]) - // Project commands first (more specific), then user commands - return [...projectCommands, ...userCommands] + // Filter out disabled plugins before scanning + const enabledPlugins = installedPlugins.filter( + (p) => !disabledPlugins.includes(p.source) + ) + + // Scan enabled plugins for commands + const pluginCommandsPromises = enabledPlugins.map(async (plugin) => { + const paths = getPluginComponentPaths(plugin) + try { + const commands = await scanCommandsDirectory(paths.commands, "plugin") + return commands.map((cmd) => ({ ...cmd, pluginName: plugin.source })) + } catch { + return [] + } + }) + + // Scan all directories in parallel + const [userCommands, projectCommands, ...pluginCommandsArrays] = + await Promise.all([ + userCommandsPromise, + projectCommandsPromise, + ...pluginCommandsPromises, + ]) + + const pluginCommands = pluginCommandsArrays.flat() + + // Priority: project > user > plugin + return [...projectCommands, ...userCommands, ...pluginCommands] }), /** diff --git a/src/main/lib/trpc/routers/index.ts b/src/main/lib/trpc/routers/index.ts index fb42b1ea..37460594 100644 --- a/src/main/lib/trpc/routers/index.ts +++ b/src/main/lib/trpc/routers/index.ts @@ -14,6 +14,7 @@ import { agentsRouter } from "./agents" import { worktreeConfigRouter } from "./worktree-config" import { commandsRouter } from "./commands" import { voiceRouter } from "./voice" +import { pluginsRouter } from "./plugins" import { createGitRouter } from "../../git" import { BrowserWindow } from "electron" @@ -38,6 +39,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { worktreeConfig: worktreeConfigRouter, commands: commandsRouter, voice: voiceRouter, + plugins: pluginsRouter, // Git operations - named "changes" to match Superset API changes: createGitRouter(), }) diff --git a/src/main/lib/trpc/routers/plugins.ts b/src/main/lib/trpc/routers/plugins.ts new file mode 100644 index 00000000..010c546e --- /dev/null +++ b/src/main/lib/trpc/routers/plugins.ts @@ -0,0 +1,217 @@ +import { router, publicProcedure } from "../index" +import * as fs from "fs/promises" +import * as path from "path" +import matter from "gray-matter" +import { + discoverInstalledPlugins, + getPluginComponentPaths, + discoverPluginMcpServers, + clearPluginCache, +} from "../../plugins" +import { getDisabledPlugins } from "./claude-settings" + +interface PluginComponent { + name: string + description?: string +} + +interface PluginWithComponents { + name: string + version: string + path: string + source: string // e.g., "ccsetup:ccsetup" + isDisabled: boolean + components: { + commands: PluginComponent[] + skills: PluginComponent[] + agents: PluginComponent[] + mcpServers: string[] + } +} + +/** + * Validate entry name for security (prevent path traversal) + */ +function isValidEntryName(name: string): boolean { + return !name.includes("..") && !name.includes("/") && !name.includes("\\") +} + +/** + * Scan commands directory and return component info + */ +async function scanPluginCommands(dir: string): Promise { + const components: PluginComponent[] = [] + + try { + await fs.access(dir) + } catch { + return components + } + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + if (!isValidEntryName(entry.name)) continue + + const fullPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + // Recursively scan nested directories for namespaced commands + const nested = await scanPluginCommands(fullPath) + components.push(...nested) + } else if (entry.isFile() && entry.name.endsWith(".md")) { + try { + const content = await fs.readFile(fullPath, "utf-8") + const { data } = matter(content) + const baseName = entry.name.replace(/\.md$/, "") + components.push({ + name: typeof data.name === "string" ? data.name : baseName, + description: + typeof data.description === "string" ? data.description : undefined, + }) + } catch { + // Skip files that can't be read + } + } + } + } catch { + // Directory read failed + } + + return components +} + +/** + * Scan skills directory and return component info + */ +async function scanPluginSkills(dir: string): Promise { + const components: PluginComponent[] = [] + + try { + await fs.access(dir) + } catch { + return components + } + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + if (!entry.isDirectory() || !isValidEntryName(entry.name)) continue + + const skillMdPath = path.join(dir, entry.name, "SKILL.md") + try { + const content = await fs.readFile(skillMdPath, "utf-8") + const { data } = matter(content) + components.push({ + name: typeof data.name === "string" ? data.name : entry.name, + description: + typeof data.description === "string" ? data.description : undefined, + }) + } catch { + // Skill directory doesn't have SKILL.md - skip + } + } + } catch { + // Directory read failed + } + + return components +} + +/** + * Scan agents directory and return component info + */ +async function scanPluginAgents(dir: string): Promise { + const components: PluginComponent[] = [] + + try { + await fs.access(dir) + } catch { + return components + } + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md") || !isValidEntryName(entry.name)) + continue + + const fullPath = path.join(dir, entry.name) + try { + const content = await fs.readFile(fullPath, "utf-8") + const { data } = matter(content) + const baseName = entry.name.replace(/\.md$/, "") + components.push({ + name: typeof data.name === "string" ? data.name : baseName, + description: + typeof data.description === "string" ? data.description : undefined, + }) + } catch { + // Skip files that can't be read + } + } + } catch { + // Directory read failed + } + + return components +} + +export const pluginsRouter = router({ + /** + * List all installed plugins with their components and disabled status + */ + list: publicProcedure.query(async (): Promise => { + const [installedPlugins, disabledPlugins, mcpConfigs] = await Promise.all([ + discoverInstalledPlugins(), + getDisabledPlugins(), + discoverPluginMcpServers(), + ]) + + // Build a map of plugin source -> MCP server names + const pluginMcpMap = new Map() + for (const config of mcpConfigs) { + pluginMcpMap.set(config.pluginSource, Object.keys(config.mcpServers)) + } + + // Scan components for each plugin in parallel + const pluginsWithComponents = await Promise.all( + installedPlugins.map(async (plugin) => { + const paths = getPluginComponentPaths(plugin) + + const [commands, skills, agents] = await Promise.all([ + scanPluginCommands(paths.commands), + scanPluginSkills(paths.skills), + scanPluginAgents(paths.agents), + ]) + + return { + name: plugin.name, + version: plugin.version, + path: plugin.path, + source: plugin.source, + isDisabled: disabledPlugins.includes(plugin.source), + components: { + commands, + skills, + agents, + mcpServers: pluginMcpMap.get(plugin.source) || [], + }, + } + }) + ) + + return pluginsWithComponents + }), + + /** + * Clear plugin cache (forces re-scan on next list) + */ + clearCache: publicProcedure.mutation(async () => { + clearPluginCache() + return { success: true } + }), +}) diff --git a/src/main/lib/trpc/routers/skills.ts b/src/main/lib/trpc/routers/skills.ts index 73adc193..83daa2d4 100644 --- a/src/main/lib/trpc/routers/skills.ts +++ b/src/main/lib/trpc/routers/skills.ts @@ -4,11 +4,14 @@ import * as fs from "fs/promises" import * as path from "path" import * as os from "os" import matter from "gray-matter" +import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" +import { getDisabledPlugins } from "./claude-settings" -interface FileSkill { +export interface FileSkill { name: string description: string - source: "user" | "project" + source: "user" | "project" | "plugin" + pluginName?: string path: string } @@ -33,7 +36,7 @@ function parseSkillMd(content: string): { name?: string; description?: string } */ async function scanSkillsDirectory( dir: string, - source: "user" | "project", + source: "user" | "project" | "plugin", basePath?: string, // For project skills, the cwd to make paths relative to ): Promise { const skills: FileSkill[] = [] @@ -112,13 +115,39 @@ const listSkillsProcedure = publicProcedure projectSkillsPromise = scanSkillsDirectory(projectSkillsDir, "project", input.cwd) } - // Scan both directories in parallel - const [userSkills, projectSkills] = await Promise.all([ + // Get disabled plugins and discover installed plugins + const [disabledPlugins, installedPlugins] = await Promise.all([ + getDisabledPlugins(), + discoverInstalledPlugins(), + ]) + + // Filter out disabled plugins before scanning + const enabledPlugins = installedPlugins.filter( + (p) => !disabledPlugins.includes(p.source) + ) + + // Scan enabled plugins for skills + const pluginSkillsPromises = enabledPlugins.map(async (plugin) => { + const paths = getPluginComponentPaths(plugin) + try { + const skills = await scanSkillsDirectory(paths.skills, "plugin") + return skills.map((skill) => ({ ...skill, pluginName: plugin.source })) + } catch { + return [] + } + }) + + // Scan all directories in parallel + const [userSkills, projectSkills, ...pluginSkillsArrays] = await Promise.all([ userSkillsPromise, projectSkillsPromise, + ...pluginSkillsPromises, ]) - return [...projectSkills, ...userSkills] + const pluginSkills = pluginSkillsArrays.flat() + + // Priority: project > user > plugin + return [...projectSkills, ...userSkills, ...pluginSkills] }) export const skillsRouter = router({ diff --git a/src/renderer/components/dialogs/agents-settings-dialog.tsx b/src/renderer/components/dialogs/agents-settings-dialog.tsx index 7bb5e13e..d0e0b369 100644 --- a/src/renderer/components/dialogs/agents-settings-dialog.tsx +++ b/src/renderer/components/dialogs/agents-settings-dialog.tsx @@ -11,7 +11,7 @@ import { import { agentsSettingsDialogActiveTabAtom, devToolsUnlockedAtom, type SettingsTab } from "../../lib/atoms" import { trpc } from "../../lib/trpc" import { cn } from "../../lib/utils" -import { BrainFilledIcon, BugFilledIcon, CustomAgentIconFilled, FlaskFilledIcon, KeyboardFilledIcon, OriginalMCPIcon, SkillIconFilled } from "../ui/icons" +import { BrainFilledIcon, BugFilledIcon, CustomAgentIconFilled, FlaskFilledIcon, KeyboardFilledIcon, OriginalMCPIcon, PluginFilledIcon, SkillIconFilled } from "../ui/icons" import { AgentsAppearanceTab } from "./settings-tabs/agents-appearance-tab" import { AgentsBetaTab } from "./settings-tabs/agents-beta-tab" import { AgentsCustomAgentsTab } from "./settings-tabs/agents-custom-agents-tab" @@ -23,6 +23,7 @@ import { AgentsPreferencesTab } from "./settings-tabs/agents-preferences-tab" import { AgentsProfileTab } from "./settings-tabs/agents-profile-tab" import { AgentsProjectWorktreeTab } from "./settings-tabs/agents-project-worktree-tab" import { AgentsSkillsTab } from "./settings-tabs/agents-skills-tab" +import { AgentsPluginsTab } from "./settings-tabs/agents-plugins-tab" // GitHub avatar icon with loading placeholder function GitHubAvatarIcon({ gitOwner, className }: { gitOwner: string; className?: string }) { @@ -135,6 +136,12 @@ const ADVANCED_TABS_BASE = [ icon: OriginalMCPIcon, description: "Model Context Protocol servers", }, + { + id: "plugins" as SettingsTab, + label: "Plugins", + icon: PluginFilledIcon, + description: "Manage installed plugins", + }, { id: "beta" as SettingsTab, label: "Beta", @@ -362,6 +369,8 @@ export function AgentsSettingsDialog({ return case "mcp": return + case "plugins": + return case "beta": return case "debug": diff --git a/src/renderer/components/dialogs/mcp-approval-dialog.tsx b/src/renderer/components/dialogs/mcp-approval-dialog.tsx new file mode 100644 index 00000000..641be37a --- /dev/null +++ b/src/renderer/components/dialogs/mcp-approval-dialog.tsx @@ -0,0 +1,222 @@ +"use client" + +import { useAtom } from "jotai" +import { Shield, Terminal, X } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { + mcpApprovalDialogOpenAtom, + pendingMcpApprovalsAtom, +} from "../../lib/atoms" +import { trpc } from "../../lib/trpc" +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogBody, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, +} from "../ui/alert-dialog" +import { Button } from "../ui/button" +import { IconSpinner } from "../ui/icons" + +export function McpApprovalDialog() { + const [open, setOpen] = useAtom(mcpApprovalDialogOpenAtom) + const [pendingApprovals, setPendingApprovals] = useAtom(pendingMcpApprovalsAtom) + const [isApproving, setIsApproving] = useState(false) + const [currentIndex, setCurrentIndex] = useState(0) + + const utils = trpc.useUtils() + const approveServerMutation = trpc.claudeSettings.approvePluginMcpServer.useMutation() + const approveAllMutation = trpc.claudeSettings.approveAllPluginMcpServers.useMutation() + + const currentApproval = pendingApprovals[currentIndex] + const hasMore = currentIndex < pendingApprovals.length - 1 + + // Group approvals by plugin source for "Allow All" feature + const currentPluginApprovals = currentApproval + ? pendingApprovals.filter(a => a.pluginSource === currentApproval.pluginSource) + : [] + const hasMultipleFromSamePlugin = currentPluginApprovals.length > 1 + + const handleAllow = async () => { + if (!currentApproval) return + setIsApproving(true) + try { + await approveServerMutation.mutateAsync({ identifier: currentApproval.identifier }) + // Invalidate queries + utils.claudeSettings.getApprovedPluginMcpServers.invalidate() + utils.claude.getPendingPluginMcpApprovals.invalidate() + utils.claude.getAllMcpConfig.invalidate() + + // Move to next or close + if (hasMore) { + setCurrentIndex(currentIndex + 1) + } else { + setOpen(false) + setPendingApprovals([]) + setCurrentIndex(0) + } + } catch (error) { + console.error("Failed to approve MCP server:", error) + toast.error("Failed to approve MCP server", { + description: error instanceof Error ? error.message : "Unknown error", + }) + } finally { + setIsApproving(false) + } + } + + const handleAllowAll = async () => { + if (!currentApproval) return + setIsApproving(true) + try { + const serverNames = currentPluginApprovals.map(a => a.serverName) + await approveAllMutation.mutateAsync({ + pluginSource: currentApproval.pluginSource, + serverNames, + }) + // Invalidate queries + utils.claudeSettings.getApprovedPluginMcpServers.invalidate() + utils.claude.getPendingPluginMcpApprovals.invalidate() + utils.claude.getAllMcpConfig.invalidate() + + // Remove all from same plugin and move to next + const remaining = pendingApprovals.filter(a => a.pluginSource !== currentApproval.pluginSource) + if (remaining.length > 0) { + setPendingApprovals(remaining) + setCurrentIndex(0) + } else { + setOpen(false) + setPendingApprovals([]) + setCurrentIndex(0) + } + } catch (error) { + console.error("Failed to approve all MCP servers:", error) + toast.error("Failed to approve MCP servers", { + description: error instanceof Error ? error.message : "Unknown error", + }) + } finally { + setIsApproving(false) + } + } + + const handleDeny = () => { + // Skip this approval without approving + if (hasMore) { + setCurrentIndex(currentIndex + 1) + } else { + setOpen(false) + setPendingApprovals([]) + setCurrentIndex(0) + } + } + + const handleClose = () => { + setOpen(false) + // Keep pending approvals - they'll be shown again next time + setCurrentIndex(0) + } + + if (!currentApproval) return null + + return ( + + + {/* Close button */} + + + +
+
+ +
+
+ MCP Server Approval + + A plugin wants to run an MCP server + +
+
+
+ + + {/* Progress indicator */} + {pendingApprovals.length > 1 && ( +
+ {currentIndex + 1} of {pendingApprovals.length} servers +
+ )} + + {/* Server details */} +
+
+
Plugin
+
{currentApproval.pluginName}
+
{currentApproval.pluginSource}
+
+ +
+
MCP Server
+
{currentApproval.serverName}
+
+ +
+
Command
+
+ +
+ {currentApproval.command} + {currentApproval.args.length > 0 && ( + {currentApproval.args.join(" ")} + )} +
+
+
+
+ + {/* Security note */} +

+ MCP servers can execute commands on your system. Only approve servers from plugins you trust. +

+
+ + + +
+ {hasMultipleFromSamePlugin && ( + + )} + +
+
+
+
+ ) +} diff --git a/src/renderer/components/dialogs/settings-tabs/agents-mcp-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-mcp-tab.tsx index 8e1532e3..598568f0 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-mcp-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-mcp-tab.tsx @@ -1,13 +1,19 @@ "use client" -import { ChevronRight, ExternalLink, Loader2, RefreshCw } from "lucide-react" -import { AnimatePresence, motion } from "motion/react" +import { ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react" import { useCallback, useEffect, useMemo, useState } from "react" import { toast } from "sonner" import { trpc } from "../../../lib/trpc" import { cn } from "../../../lib/utils" import { Button } from "../../ui/button" import { OriginalMCPIcon } from "../../ui/icons" +import { + AddMcpServerDialog, + EditMcpServerDialog, + McpServerRow, + type McpServer, + type ScopeType, +} from "./mcp" // Hook to detect narrow screen function useIsNarrowScreen(): boolean { @@ -26,156 +32,18 @@ function useIsNarrowScreen(): boolean { return isNarrow } -// Status indicator dot -function StatusDot({ status }: { status: string }) { - return ( - - ) -} - -// Get status text -function getStatusText(status: string): string { - switch (status) { - case "connected": - return "Connected" - case "failed": - return "Failed" - case "needs-auth": - return "Needs auth" - case "pending": - return "Connecting..." - default: - return status - } -} - -interface McpServer { - name: string - status: string - tools: string[] - needsAuth: boolean - config: Record - serverInfo?: { name: string; version: string } - error?: string -} - -interface ServerRowProps { - server: McpServer - isExpanded: boolean - onToggle: () => void - onAuth?: () => void -} - -function ServerRow({ server, isExpanded, onToggle, onAuth }: ServerRowProps) { - const { tools, needsAuth } = server - const hasTools = tools.length > 0 - const isConnected = server.status === "connected" - - return ( -
-
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggle(); } } : undefined} - className={cn( - "w-full flex items-center gap-3 p-3 text-left transition-colors", - hasTools && "hover:bg-muted/50 cursor-pointer", - !hasTools && "cursor-default", - )} - > - {/* Expand chevron */} - - - {/* Status dot */} - - - {/* Server info */} -
-
- - {server.name} - - {server.serverInfo?.version && ( - - v{server.serverInfo.version} - - )} -
- {server.error && ( -

- {server.error} -

- )} -
- - {/* Status / tool count */} - - {isConnected - ? (hasTools ? `${tools.length} tool${tools.length !== 1 ? "s" : ""}` : "No tools") - : getStatusText(server.status)} - - - {/* Authenticate button */} - {needsAuth && onAuth && ( - - )} -
- - {/* Expanded tools list */} - - {isExpanded && hasTools && ( - -
- {tools.map((tool) => ( -
- {tool} -
- ))} -
-
- )} -
-
- ) -} - export function AgentsMcpTab() { const isNarrowScreen = useIsNarrowScreen() const [expandedServer, setExpandedServer] = useState(null) + // Dialog state + const [addDialogOpen, setAddDialogOpen] = useState(false) + const [editingServer, setEditingServer] = useState<{ + server: McpServer + scope: ScopeType + projectPath: string | null + } | null>(null) + // Fetch ALL MCP config (global + all projects) - includes tools for connected servers const { data: allMcpConfig, isLoading: isLoadingConfig, refetch } = trpc.claude.getAllMcpConfig.useQuery() @@ -185,6 +53,7 @@ export function AgentsMcpTab() { // tRPC const startOAuthMutation = trpc.claude.startMcpOAuth.useMutation() const openInFinderMutation = trpc.external.openInFinder.useMutation() + const updateMutation = trpc.claude.updateMcpServer.useMutation() // Process groups for display (filter out empty groups) const groups = useMemo( @@ -236,10 +105,7 @@ export function AgentsMcpTab() { toast.error(result.error || "Authentication failed") } } catch (error) { - // Extract actual error message from tRPC error - const message = error instanceof Error ? error.message : "Authentication failed"; - console.error(`[MCP Auth] Error authenticating ${serverName}:`, error); - toast.error(message) + toast.error("Authentication failed") } } @@ -247,6 +113,43 @@ export function AgentsMcpTab() { openInFinderMutation.mutate("~/.claude.json") } + const handleToggleEnabled = async ( + server: McpServer, + scope: ScopeType, + projectPath: string | null, + enabled: boolean + ) => { + try { + const result = await updateMutation.mutateAsync({ + name: server.name, + scope, + projectPath: projectPath || undefined, + disabled: !enabled, + }) + if (result.success) { + toast.success(enabled ? "Server enabled" : "Server disabled") + await handleRefresh(true) + } else { + toast.error(result.error || "Failed to update server") + } + } catch (error) { + toast.error("Failed to update server") + } + } + + // Determine scope from group name + const getScopeFromGroup = (groupName: string): ScopeType => { + if (groupName === "Global" || groupName.startsWith("Plugin:")) { + return "global" + } + return "project" + } + + // Check if a group contains editable servers (not plugins) + const isEditableGroup = (groupName: string): boolean => { + return !groupName.startsWith("Plugin:") + } + return (
{/* Header */} @@ -265,6 +168,16 @@ export function AgentsMcpTab() { )} +
+
)} @@ -324,40 +237,88 @@ export function AgentsMcpTab() {

No MCP servers configured

-

- Add servers to{" "} - ~/.claude.json -

+ ) : (
- {groups.map((group) => ( -
- {/* Group label */} -

- {group.groupName} -

- {/* Server rows */} -
-
- {group.mcpServers.map((server) => ( - handleToggleServer(`${group.groupName}-${server.name}`)} - onAuth={() => handleAuth(server.name, group.projectPath)} - /> - ))} + {groups.map((group) => { + const scope = getScopeFromGroup(group.groupName) + const isEditable = isEditableGroup(group.groupName) + + return ( +
+ {/* Group label */} +

+ {group.groupName} +

+ {/* Server rows */} +
+
+ {group.mcpServers.map((server) => ( + handleToggleServer(`${group.groupName}-${server.name}`)} + onAuth={() => handleAuth(server.name, group.projectPath)} + onEdit={ + isEditable + ? () => + setEditingServer({ + server, + scope, + projectPath: group.projectPath, + }) + : undefined + } + onToggleEnabled={ + isEditable + ? (enabled) => + handleToggleEnabled(server, scope, group.projectPath, enabled) + : undefined + } + isEditable={isEditable} + showToggle={isEditable} + /> + ))} +
-
- ))} + ) + })}
)}
{/* Bottom spacer for scroll padding */}
+ + {/* Add Server Dialog */} + handleRefresh(true)} + /> + + {/* Edit Server Dialog */} + { + if (!open) setEditingServer(null) + }} + server={editingServer?.server || null} + scope={editingServer?.scope || "global"} + projectPath={editingServer?.projectPath || null} + onSuccess={() => handleRefresh(true)} + onDelete={() => handleRefresh(true)} + />
) } diff --git a/src/renderer/components/dialogs/settings-tabs/agents-plugins-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-plugins-tab.tsx new file mode 100644 index 00000000..1aff88fc --- /dev/null +++ b/src/renderer/components/dialogs/settings-tabs/agents-plugins-tab.tsx @@ -0,0 +1,435 @@ +import { useState, useEffect, useMemo } from "react" +import { ChevronRight, RefreshCw, Loader2, Check, Shield } from "lucide-react" +import { motion, AnimatePresence } from "motion/react" +import { trpc } from "../../../lib/trpc" +import { cn } from "../../../lib/utils" +import { PluginFilledIcon } from "../../ui/icons" +import { Switch } from "../../ui/switch" +import { Button } from "../../ui/button" + +// Hook to detect narrow screen +function useIsNarrowScreen(): boolean { + const [isNarrow, setIsNarrow] = useState(false) + + useEffect(() => { + const checkWidth = () => { + setIsNarrow(window.innerWidth <= 768) + } + + checkWidth() + window.addEventListener("resize", checkWidth) + return () => window.removeEventListener("resize", checkWidth) + }, []) + + return isNarrow +} + +interface PluginComponent { + name: string + description?: string +} + +interface PluginWithComponents { + name: string + version: string + path: string + source: string + isDisabled: boolean + components: { + commands: PluginComponent[] + skills: PluginComponent[] + agents: PluginComponent[] + mcpServers: string[] + } +} + +export function AgentsPluginsTab() { + const isNarrowScreen = useIsNarrowScreen() + const [expandedPluginSource, setExpandedPluginSource] = useState(null) + + const utils = trpc.useUtils() + const { data: plugins = [], isLoading, refetch } = trpc.plugins.list.useQuery() + const { data: approvedServers = [] } = trpc.claudeSettings.getApprovedPluginMcpServers.useQuery() + const clearCacheMutation = trpc.plugins.clearCache.useMutation() + const setPluginDisabledMutation = trpc.claudeSettings.setPluginDisabled.useMutation() + const approveServerMutation = trpc.claudeSettings.approvePluginMcpServer.useMutation() + const revokeServerMutation = trpc.claudeSettings.revokePluginMcpServer.useMutation() + const openInFinderMutation = trpc.external.openInFinder.useMutation() + + // Build a set of approved servers for quick lookup + const approvedServersSet = useMemo(() => new Set(approvedServers), [approvedServers]) + + const handleExpandPlugin = (pluginSource: string) => { + setExpandedPluginSource(expandedPluginSource === pluginSource ? null : pluginSource) + } + + const handleToggleEnabled = async (pluginSource: string, enabled: boolean) => { + await setPluginDisabledMutation.mutateAsync({ + pluginSource, + disabled: !enabled, + }) + // Invalidate queries that depend on disabled plugins + utils.plugins.list.invalidate() + utils.skills.list.invalidate() + utils.agents.list.invalidate() + utils.commands.list.invalidate() + } + + const handleRefresh = async () => { + await clearCacheMutation.mutateAsync() + refetch() + } + + const handleOpenInFinder = (path: string) => { + openInFinderMutation.mutate(path) + } + + const handleApproveServer = async (pluginSource: string, serverName: string) => { + const identifier = `${pluginSource}:${serverName}` + await approveServerMutation.mutateAsync({ identifier }) + utils.claudeSettings.getApprovedPluginMcpServers.invalidate() + utils.claude.getAllMcpConfig.invalidate() + } + + const handleRevokeServer = async (pluginSource: string, serverName: string) => { + const identifier = `${pluginSource}:${serverName}` + await revokeServerMutation.mutateAsync({ identifier }) + utils.claudeSettings.getApprovedPluginMcpServers.invalidate() + utils.claude.getAllMcpConfig.invalidate() + } + + return ( +
+ {/* Header - hidden on narrow screens */} + {!isNarrowScreen && ( +
+
+

Plugins

+ +
+
+ )} + + {/* Info Section */} +
+
+

+ How Plugins Work +

+

+ Plugins extend 1Code with additional commands, skills, agents, and MCP servers. + Disable a plugin to hide its components from the UI. +

+
+
+

+ Installing Plugins +

+

+ Install plugins using claude /plugin install <name> +

+
+
+ + {/* Plugins List */} +
+ {isLoading ? ( +
+ Loading plugins... +
+ ) : plugins.length === 0 ? ( +
+ +

+ No plugins installed +

+

+ Install plugins from ~/.claude/plugins/marketplaces/ +

+
+ ) : ( +
+
+ {plugins.map((plugin) => ( + handleExpandPlugin(plugin.source)} + onToggleEnabled={(enabled) => handleToggleEnabled(plugin.source, enabled)} + onOpenInFinder={() => handleOpenInFinder(plugin.path)} + isToggling={setPluginDisabledMutation.isPending} + approvedServersSet={approvedServersSet} + onApproveServer={(serverName) => handleApproveServer(plugin.source, serverName)} + onRevokeServer={(serverName) => handleRevokeServer(plugin.source, serverName)} + isApprovingOrRevoking={approveServerMutation.isPending || revokeServerMutation.isPending} + /> + ))} +
+
+ )} +
+
+ ) +} + +function PluginRow({ + plugin, + isExpanded, + onToggle, + onToggleEnabled, + onOpenInFinder, + isToggling, + approvedServersSet, + onApproveServer, + onRevokeServer, + isApprovingOrRevoking, +}: { + plugin: PluginWithComponents + isExpanded: boolean + onToggle: () => void + onToggleEnabled: (enabled: boolean) => void + onOpenInFinder: () => void + isToggling: boolean + approvedServersSet: Set + onApproveServer: (serverName: string) => void + onRevokeServer: (serverName: string) => void + isApprovingOrRevoking: boolean +}) { + const totalComponents = + plugin.components.commands.length + + plugin.components.skills.length + + plugin.components.agents.length + + plugin.components.mcpServers.length + + return ( +
+
+ + +
+ + + {isExpanded && ( + +
+
+ {/* Path */} +
+ Path + +
+ + {/* Source */} +
+ Source +

+ {plugin.source} +

+
+ + {/* Commands */} + {plugin.components.commands.length > 0 && ( +
+ Commands +
+ {plugin.components.commands.map((cmd) => ( + + /{cmd.name} + + ))} +
+
+ )} + + {/* Skills */} + {plugin.components.skills.length > 0 && ( +
+ Skills +
+ {plugin.components.skills.map((skill) => ( + + @{skill.name} + + ))} +
+
+ )} + + {/* Agents */} + {plugin.components.agents.length > 0 && ( +
+ Agents +
+ {plugin.components.agents.map((agent) => ( + + @{agent.name} + + ))} +
+
+ )} + + {/* MCP Servers */} + {plugin.components.mcpServers.length > 0 && ( +
+ MCP Servers +
+ {plugin.components.mcpServers.map((server) => { + const identifier = `${plugin.source}:${server}` + const isApproved = approvedServersSet.has(identifier) + return ( +
+ + {server} + +
+ {isApproved ? ( +
+ + + Approved + + +
+ ) : ( +
+ + + Pending + + +
+ )} +
+ ) + })} +
+
+ )} +
+
+ + )} + +
+ ) +} diff --git a/src/renderer/components/dialogs/settings-tabs/mcp/add-mcp-server-dialog.tsx b/src/renderer/components/dialogs/settings-tabs/mcp/add-mcp-server-dialog.tsx new file mode 100644 index 00000000..f248aac2 --- /dev/null +++ b/src/renderer/components/dialogs/settings-tabs/mcp/add-mcp-server-dialog.tsx @@ -0,0 +1,70 @@ +"use client" + +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "../../../ui/dialog" +import { trpc } from "../../../../lib/trpc" +import { McpServerForm } from "./mcp-server-form" +import type { McpServerFormData } from "./types" + +interface AddMcpServerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess?: () => void +} + +export function AddMcpServerDialog({ + open, + onOpenChange, + onSuccess, +}: AddMcpServerDialogProps) { + const addMutation = trpc.claude.addMcpServer.useMutation({ + onSuccess: (result) => { + if (result.success) { + toast.success("MCP server added") + onOpenChange(false) + onSuccess?.() + } else { + toast.error(result.error || "Failed to add server") + } + }, + onError: (error) => { + toast.error(error.message || "Failed to add server") + }, + }) + + const handleSubmit = (data: McpServerFormData) => { + addMutation.mutate({ + name: data.name, + scope: data.scope, + projectPath: data.projectPath, + transport: data.transport, + command: data.command, + args: data.args, + env: data.env, + url: data.url, + authType: data.authType, + bearerToken: data.bearerToken, + }) + } + + return ( + + + + Add MCP Server + + onOpenChange(false)} + isSubmitting={addMutation.isPending} + submitLabel="Add Server" + /> + + + ) +} diff --git a/src/renderer/components/dialogs/settings-tabs/mcp/delete-server-confirm.tsx b/src/renderer/components/dialogs/settings-tabs/mcp/delete-server-confirm.tsx new file mode 100644 index 00000000..efa220e5 --- /dev/null +++ b/src/renderer/components/dialogs/settings-tabs/mcp/delete-server-confirm.tsx @@ -0,0 +1,88 @@ +"use client" + +import { toast } from "sonner" +import { + AlertDialog, + AlertDialogAction, + AlertDialogBody, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../../../ui/alert-dialog" +import { trpc } from "../../../../lib/trpc" +import type { McpServer, ScopeType } from "./types" + +interface DeleteServerConfirmProps { + open: boolean + onOpenChange: (open: boolean) => void + server: McpServer | null + scope: ScopeType + projectPath: string | null + onSuccess?: () => void +} + +export function DeleteServerConfirm({ + open, + onOpenChange, + server, + scope, + projectPath, + onSuccess, +}: DeleteServerConfirmProps) { + const removeMutation = trpc.claude.removeMcpServer.useMutation({ + onSuccess: (result) => { + if (result.success) { + toast.success(`Removed "${server?.name}"`) + onOpenChange(false) + onSuccess?.() + } else { + toast.error(result.error || "Failed to remove server") + } + }, + onError: (error) => { + toast.error(error.message || "Failed to remove server") + }, + }) + + const handleDelete = () => { + if (!server) return + removeMutation.mutate({ + name: server.name, + scope, + projectPath: projectPath || undefined, + }) + } + + if (!server) return null + + return ( + + + + Remove MCP Server + + + + Are you sure you want to remove {server.name}? This will + delete the server configuration from your settings. + + + + + Cancel + + + {removeMutation.isPending ? "Removing..." : "Remove"} + + + + + ) +} diff --git a/src/renderer/components/dialogs/settings-tabs/mcp/edit-mcp-server-dialog.tsx b/src/renderer/components/dialogs/settings-tabs/mcp/edit-mcp-server-dialog.tsx new file mode 100644 index 00000000..8e91b6fc --- /dev/null +++ b/src/renderer/components/dialogs/settings-tabs/mcp/edit-mcp-server-dialog.tsx @@ -0,0 +1,375 @@ +"use client" + +import { formatDistanceToNow } from "date-fns" +import { Eye, EyeOff, Trash2, X } from "lucide-react" +import { useMemo, useState } from "react" +import { toast } from "sonner" +import { Button } from "../../../ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "../../../ui/dialog" +import { Input } from "../../../ui/input" +import { Label } from "../../../ui/label" +import { cn } from "../../../../lib/utils" +import { trpc } from "../../../../lib/trpc" +import type { McpServer, ScopeType } from "./types" + +interface EditMcpServerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + server: McpServer | null + scope: ScopeType + projectPath: string | null + onSuccess?: () => void + onDelete?: () => void +} + +export function EditMcpServerDialog({ + open, + onOpenChange, + server, + scope, + projectPath, + onSuccess, + onDelete, +}: EditMcpServerDialogProps) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [showToken, setShowToken] = useState(false) + const [bearerToken, setBearerToken] = useState("") + + const updateMutation = trpc.claude.updateMcpServer.useMutation({ + onSuccess: (result) => { + if (result.success) { + toast.success("MCP server updated") + onOpenChange(false) + onSuccess?.() + } else { + toast.error(result.error || "Failed to update server") + } + }, + onError: (error) => { + toast.error(error.message || "Failed to update server") + }, + }) + + const removeMutation = trpc.claude.removeMcpServer.useMutation({ + onSuccess: (result) => { + if (result.success) { + toast.success("MCP server removed") + onOpenChange(false) + onDelete?.() + } else { + toast.error(result.error || "Failed to remove server") + } + }, + onError: (error) => { + toast.error(error.message || "Failed to remove server") + }, + }) + + const setTokenMutation = trpc.claude.setMcpBearerToken.useMutation({ + onSuccess: (result) => { + if (result.success) { + toast.success("Token updated") + setBearerToken("") + onSuccess?.() + } else { + toast.error(result.error || "Failed to update token") + } + }, + onError: (error) => { + toast.error(error.message || "Failed to update token") + }, + }) + + const startOAuthMutation = trpc.claude.startMcpOAuth.useMutation({ + onSuccess: (result) => { + if (result.success) { + toast.success("Authentication successful") + onSuccess?.() + } else { + toast.error(result.error || "Authentication failed") + } + }, + onError: (error) => { + toast.error(error.message || "Authentication failed") + }, + }) + + const handleToggleDisabled = () => { + if (!server) return + const isCurrentlyDisabled = server.config._disabled === true + updateMutation.mutate({ + name: server.name, + scope, + projectPath: projectPath || undefined, + disabled: !isCurrentlyDisabled, + }) + } + + const handleDelete = () => { + if (!server) return + removeMutation.mutate({ + name: server.name, + scope, + projectPath: projectPath || undefined, + }) + } + + const handleSetToken = () => { + if (!server || !bearerToken.trim()) return + setTokenMutation.mutate({ + name: server.name, + scope, + projectPath: projectPath || undefined, + token: bearerToken.trim(), + }) + } + + const handleOAuth = () => { + if (!server) return + startOAuthMutation.mutate({ + serverName: server.name, + projectPath: projectPath || "__global__", + }) + } + + // Server config info + const isStdio = !!server?.config.command + const isHttp = !!server?.config.url + const authType = server?.config.authType as string | undefined + const hasOAuthToken = !!(server?.config._oauth as Record)?.accessToken + const oauthExpiresAt = (server?.config._oauth as Record)?.expiresAt as number | undefined + const isDisabled = server?.config._disabled === true + + const tokenExpiryText = useMemo(() => { + if (!oauthExpiresAt) return null + const expiresDate = new Date(oauthExpiresAt * 1000) + if (expiresDate < new Date()) { + return "Token expired" + } + return `Expires ${formatDistanceToNow(expiresDate, { addSuffix: true })}` + }, [oauthExpiresAt]) + + if (!server) return null + + return ( + + + + Edit MCP Server + + +
+ {/* Server Info */} +
+
+ {server.name} + {server.serverInfo?.version && ( + + v{server.serverInfo.version} + + )} +
+
+ {isStdio && ( + <> + {server.config.command as string} + {(server.config.args as string[])?.length > 0 && ( + {(server.config.args as string[]).join(" ")} + )} + + )} + {isHttp && ( + {server.config.url as string} + )} +
+
+ + {/* Status */} +
+ +
+ + + {server.status === "connected" && "Connected"} + {server.status === "needs-auth" && "Needs authentication"} + {server.status === "failed" && "Failed to connect"} + {server.status === "pending" && "Connecting..."} + {server.status === "disabled" && "Disabled"} + +
+ {server.error && ( +

{server.error}

+ )} +
+ + {/* Enable/Disable Toggle */} +
+
+ +

+ {isDisabled ? "Server is disabled and won't be loaded" : "Server is active"} +

+
+ +
+ + {/* Authentication Section */} + {isHttp && ( +
+ + + {/* OAuth Status */} + {(authType === "oauth" || hasOAuthToken) && ( +
+
+
+ + + {hasOAuthToken ? "OAuth Connected" : "Not authenticated"} + +
+ +
+ {tokenExpiryText && ( +

{tokenExpiryText}

+ )} +
+ )} + + {/* Bearer Token */} + {(authType === "bearer" || server.needsAuth) && !hasOAuthToken && ( +
+ +
+
+ setBearerToken(e.target.value)} + placeholder="Enter new token..." + className="pr-16" + /> +
+ + {bearerToken && ( + + )} +
+
+ +
+ {server.config.headers && ( +

+ A token is already configured. Enter a new one to replace it. +

+ )} +
+ )} +
+ )} + + {/* Tools */} + {server.tools.length > 0 && ( +
+ +
+ {server.tools.map((tool) => ( +
+ {tool} +
+ ))} +
+
+ )} + + {/* Delete Section */} +
+ {!showDeleteConfirm ? ( + + ) : ( +
+ + Are you sure? + + + +
+ )} +
+
+
+
+ ) +} diff --git a/src/renderer/components/dialogs/settings-tabs/mcp/index.ts b/src/renderer/components/dialogs/settings-tabs/mcp/index.ts new file mode 100644 index 00000000..e3f86563 --- /dev/null +++ b/src/renderer/components/dialogs/settings-tabs/mcp/index.ts @@ -0,0 +1,6 @@ +export * from "./types" +export * from "./mcp-server-form" +export * from "./mcp-server-row" +export * from "./add-mcp-server-dialog" +export * from "./edit-mcp-server-dialog" +export * from "./delete-server-confirm" diff --git a/src/renderer/components/dialogs/settings-tabs/mcp/mcp-server-form.tsx b/src/renderer/components/dialogs/settings-tabs/mcp/mcp-server-form.tsx new file mode 100644 index 00000000..32acd567 --- /dev/null +++ b/src/renderer/components/dialogs/settings-tabs/mcp/mcp-server-form.tsx @@ -0,0 +1,387 @@ +"use client" + +import { Eye, EyeOff, X } from "lucide-react" +import { useEffect, useState } from "react" +import { Button } from "../../../ui/button" +import { Input } from "../../../ui/input" +import { Label } from "../../../ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../ui/select" +import { cn } from "../../../../lib/utils" +import { trpc } from "../../../../lib/trpc" +import type { AuthType, McpServerFormData, ScopeType, TransportType } from "./types" + +interface McpServerFormProps { + initialData?: Partial + onSubmit: (data: McpServerFormData) => void + onCancel: () => void + isSubmitting?: boolean + submitLabel?: string + isEditing?: boolean +} + +export function McpServerForm({ + initialData, + onSubmit, + onCancel, + isSubmitting = false, + submitLabel = "Add Server", + isEditing = false, +}: McpServerFormProps) { + const { data: projects } = trpc.projects.list.useQuery() + + const [name, setName] = useState(initialData?.name || "") + const [scope, setScope] = useState(initialData?.scope || "global") + const [projectPath, setProjectPath] = useState(initialData?.projectPath || "") + const [transport, setTransport] = useState(initialData?.transport || "stdio") + + // Stdio fields + const [command, setCommand] = useState(initialData?.command || "") + const [argsText, setArgsText] = useState(initialData?.args?.join("\n") || "") + const [envText, setEnvText] = useState( + initialData?.env + ? Object.entries(initialData.env) + .map(([k, v]) => `${k}=${v}`) + .join("\n") + : "" + ) + + // HTTP fields + const [url, setUrl] = useState(initialData?.url || "") + const [authType, setAuthType] = useState(initialData?.authType || "none") + const [bearerToken, setBearerToken] = useState(initialData?.bearerToken || "") + const [showToken, setShowToken] = useState(false) + + // Validation + const [errors, setErrors] = useState>({}) + + // Update projectPath when scope changes and no project is selected + useEffect(() => { + if (scope === "project" && !projectPath && projects && projects.length > 0) { + setProjectPath(projects[0].path) + } + }, [scope, projectPath, projects]) + + const validateForm = (): boolean => { + const newErrors: Record = {} + + if (!name.trim()) { + newErrors.name = "Name is required" + } else if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + newErrors.name = "Only letters, numbers, underscores, and hyphens allowed" + } + + if (scope === "project" && !projectPath) { + newErrors.projectPath = "Please select a project" + } + + if (transport === "stdio") { + if (!command.trim()) { + newErrors.command = "Command is required" + } + } else { + if (!url.trim()) { + newErrors.url = "URL is required" + } else { + try { + new URL(url) + } catch { + newErrors.url = "Invalid URL format" + } + } + + if (authType === "bearer" && !bearerToken.trim()) { + newErrors.bearerToken = "Token is required for Bearer auth" + } + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!validateForm()) return + + const args = argsText + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + + const env: Record = {} + envText + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + .forEach((line) => { + const idx = line.indexOf("=") + if (idx > 0) { + env[line.slice(0, idx)] = line.slice(idx + 1) + } + }) + + const data: McpServerFormData = { + name, + scope, + projectPath: scope === "project" ? projectPath : undefined, + transport, + ...(transport === "stdio" + ? { + command, + args: args.length > 0 ? args : undefined, + env: Object.keys(env).length > 0 ? env : undefined, + } + : { + url, + authType, + bearerToken: authType === "bearer" ? bearerToken : undefined, + }), + } + + onSubmit(data) + } + + return ( +
+ {/* Server Name */} +
+ + setName(e.target.value)} + placeholder="my-mcp-server" + disabled={isEditing} + className={cn(errors.name && "border-destructive")} + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Scope */} +
+ +
+ + +
+ {scope === "project" && ( +
+ + {errors.projectPath && ( +

{errors.projectPath}

+ )} +
+ )} +
+ + {/* Transport Type */} +
+ +
+ + +
+
+ + {/* Stdio Fields */} + {transport === "stdio" && ( + <> +
+ + setCommand(e.target.value)} + placeholder="npx" + className={cn(errors.command && "border-destructive")} + /> + {errors.command && ( +

{errors.command}

+ )} +
+ +
+ +