diff --git a/README.md b/README.md index bab60d4..62df09a 100644 --- a/README.md +++ b/README.md @@ -284,11 +284,31 @@ CodeTyper has access to these built-in tools: Connect external MCP (Model Context Protocol) servers for extended capabilities: ```bash -# In the TUI -/mcp -# Then add a new server +# Browse and search available servers +/mcp browse # Interactive browser +/mcp search # Search by keyword +/mcp popular # Show popular servers +/mcp categories # List all categories + +# Install a server +/mcp install sqlite +/mcp install github + +# Manage servers +/mcp status # Show connected servers +/mcp connect # Connect all servers +/mcp disconnect # Disconnect all servers +/mcp tools # List available tools +/mcp add # Add custom server ``` +**MCP Browser Features:** +- Search by name, description, or tags +- Filter by category (database, web, AI, etc.) +- View server details and required environment variables +- One-click installation and connection +- 15+ verified servers from Anthropic + ## Extensibility ### Hooks System diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 650aa38..61dc467 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -51,6 +51,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use tools from connected servers - `/mcp` command for server management - Status display in UI + - **MCP Server Browser**: Search and discover MCP servers + - Interactive browser with `/mcp browse` + - Search servers with `/mcp search ` + - Filter by category (database, web, AI, dev-tools, etc.) + - View server details and required environment variables + - One-click install with `/mcp install ` + - 15+ curated verified servers from Anthropic + - Registry integration with Smithery - **Reasoning System**: Advanced agent orchestration - Memory selection for context optimization diff --git a/src/commands/components/chat/mcp/handle-mcp.ts b/src/commands/components/chat/mcp/handle-mcp.ts index 2eb5476..3cf0b33 100644 --- a/src/commands/components/chat/mcp/handle-mcp.ts +++ b/src/commands/components/chat/mcp/handle-mcp.ts @@ -10,7 +10,15 @@ import { connectAllServers, disconnectAllServers, getAllTools, + searchServers, + getPopularServers, + installServerById, + getCategoriesWithCounts, } from "@services/mcp/index"; +import { + MCP_CATEGORY_LABELS, + MCP_CATEGORY_ICONS, +} from "@constants/mcp-registry"; import { showMCPStatus } from "@commands/components/chat/mcp/show-mcp-status"; import { appStore } from "@tui-solid/context/app"; @@ -26,13 +34,18 @@ export const handleMCP = async (args: string[]): Promise => { disconnect: handleDisconnect, tools: handleTools, add: handleAdd, + search: handleSearch, + browse: handleBrowse, + install: handleInstall, + popular: handlePopular, + categories: handleCategories, }; const handler = handlers[subcommand]; if (!handler) { console.log(chalk.yellow(`Unknown MCP command: ${subcommand}`)); console.log( - chalk.gray("Available: status, connect, disconnect, tools, add"), + chalk.gray("Available: status, connect, disconnect, tools, add, search, browse, install, popular, categories"), ); return; } @@ -141,3 +154,137 @@ const handleTools = async (_args: string[]): Promise => { const handleAdd = async (_args: string[]): Promise => { appStore.setMode("mcp_add"); }; + +/** + * Search for MCP servers + */ +const handleSearch = async (args: string[]): Promise => { + const query = args.join(" "); + + if (!query) { + console.log(chalk.yellow("\nUsage: /mcp search ")); + console.log(chalk.gray("Example: /mcp search database")); + console.log(chalk.gray("Or use /mcp browse for interactive browser")); + console.log(); + return; + } + + console.log(chalk.gray(`\nSearching for "${query}"...`)); + + try { + const result = await searchServers({ query, limit: 10 }); + + if (result.servers.length === 0) { + console.log(chalk.yellow("\nNo servers found matching your search.")); + console.log(chalk.gray("Try /mcp popular to see popular servers")); + console.log(); + return; + } + + console.log(chalk.bold(`\nFound ${result.total} servers:\n`)); + + for (const server of result.servers) { + const icon = MCP_CATEGORY_ICONS[server.category]; + const verified = server.verified ? chalk.green(" āœ“") : ""; + console.log(`${icon} ${chalk.white(server.name)}${verified}`); + console.log(` ${chalk.gray(server.description)}`); + console.log(` ${chalk.cyan("Install:")} /mcp install ${server.id}`); + console.log(); + } + } catch (error) { + console.log(chalk.red(`\nSearch failed: ${error}`)); + console.log(); + } +}; + +/** + * Open interactive MCP browser + */ +const handleBrowse = async (_args: string[]): Promise => { + appStore.setMode("mcp_browse"); +}; + +/** + * Install an MCP server by ID + */ +const handleInstall = async (args: string[]): Promise => { + const serverId = args[0]; + + if (!serverId) { + console.log(chalk.yellow("\nUsage: /mcp install ")); + console.log(chalk.gray("Example: /mcp install sqlite")); + console.log(chalk.gray("Use /mcp search to find server IDs")); + console.log(); + return; + } + + console.log(chalk.gray(`\nInstalling ${serverId}...`)); + + try { + const result = await installServerById(serverId, { connect: true }); + + if (result.success) { + console.log(chalk.green(`\nāœ“ Installed ${result.serverName}`)); + if (result.connected) { + console.log(chalk.gray(" Server is now connected")); + } + } else { + console.log(chalk.red(`\nāœ— Installation failed: ${result.error}`)); + } + console.log(); + } catch (error) { + console.log(chalk.red(`\nInstallation failed: ${error}`)); + console.log(); + } +}; + +/** + * Show popular MCP servers + */ +const handlePopular = async (_args: string[]): Promise => { + console.log(chalk.gray("\nFetching popular servers...")); + + try { + const servers = await getPopularServers(10); + + console.log(chalk.bold("\nPopular MCP Servers:\n")); + + for (const server of servers) { + const icon = MCP_CATEGORY_ICONS[server.category]; + const verified = server.verified ? chalk.green(" āœ“") : ""; + console.log(`${icon} ${chalk.white(server.name)}${verified}`); + console.log(` ${chalk.gray(server.description)}`); + console.log(` ${chalk.cyan("Install:")} /mcp install ${server.id}`); + console.log(); + } + } catch (error) { + console.log(chalk.red(`\nFailed to fetch servers: ${error}`)); + console.log(); + } +}; + +/** + * Show MCP server categories + */ +const handleCategories = async (_args: string[]): Promise => { + console.log(chalk.gray("\nFetching categories...")); + + try { + const categories = await getCategoriesWithCounts(); + + console.log(chalk.bold("\nMCP Server Categories:\n")); + + for (const { category, count } of categories) { + const icon = MCP_CATEGORY_ICONS[category]; + const label = MCP_CATEGORY_LABELS[category]; + console.log(`${icon} ${chalk.white(label)} ${chalk.gray(`(${count} servers)`)}`); + } + + console.log(); + console.log(chalk.gray("Use /mcp search to filter by category")); + console.log(); + } catch (error) { + console.log(chalk.red(`\nFailed to fetch categories: ${error}`)); + console.log(); + } +}; diff --git a/src/constants/mcp-registry.ts b/src/constants/mcp-registry.ts new file mode 100644 index 0000000..9bd0fd1 --- /dev/null +++ b/src/constants/mcp-registry.ts @@ -0,0 +1,368 @@ +/** + * MCP Registry Constants + * + * Constants for MCP server discovery and search + */ + +import type { MCPServerCategory, MCPRegistryServer } from "@/types/mcp-registry"; + +/** + * Default registry sources + */ +export const MCP_REGISTRY_SOURCES = { + /** Official MCP servers GitHub */ + OFFICIAL: "https://raw.githubusercontent.com/modelcontextprotocol/servers/main/README.md", + /** Smithery registry API */ + SMITHERY: "https://registry.smithery.ai/servers", +} as const; + +/** + * Cache settings + */ +export const MCP_REGISTRY_CACHE = { + /** Cache duration in milliseconds (1 hour) */ + DURATION_MS: 60 * 60 * 1000, + /** Cache file name */ + FILE_NAME: "mcp-registry-cache.json", +} as const; + +/** + * Category display names + */ +export const MCP_CATEGORY_LABELS: Record = { + database: "Database", + filesystem: "File System", + web: "Web & Browser", + ai: "AI & ML", + "dev-tools": "Developer Tools", + productivity: "Productivity", + communication: "Communication", + cloud: "Cloud Services", + security: "Security", + other: "Other", +} as const; + +/** + * Category icons (emoji) + */ +export const MCP_CATEGORY_ICONS: Record = { + database: "šŸ—„ļø", + filesystem: "šŸ“", + web: "🌐", + ai: "šŸ¤–", + "dev-tools": "šŸ› ļø", + productivity: "šŸ“‹", + communication: "šŸ’¬", + cloud: "ā˜ļø", + security: "šŸ”’", + other: "šŸ“¦", +} as const; + +/** + * Search defaults + */ +export const MCP_SEARCH_DEFAULTS = { + LIMIT: 20, + SORT_BY: "popularity" as const, +} as const; + +/** + * Built-in curated server list + * These are well-known, verified MCP servers + */ +export const MCP_CURATED_SERVERS: MCPRegistryServer[] = [ + { + id: "filesystem", + name: "Filesystem", + description: "Read, write, and manage files on the local filesystem", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-filesystem", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"], + category: "filesystem", + tags: ["files", "read", "write", "directory"], + transport: "stdio", + version: "latest", + popularity: 100, + verified: true, + installHint: "Replace /path/to/dir with the directory you want to access", + updatedAt: "2024-12-01", + }, + { + id: "sqlite", + name: "SQLite", + description: "Query and manage SQLite databases", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-sqlite", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-sqlite", "/path/to/db.sqlite"], + category: "database", + tags: ["sql", "database", "query", "sqlite"], + transport: "stdio", + version: "latest", + popularity: 95, + verified: true, + installHint: "Replace /path/to/db.sqlite with your database file path", + updatedAt: "2024-12-01", + }, + { + id: "postgres", + name: "PostgreSQL", + description: "Connect to and query PostgreSQL databases", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-postgres", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-postgres"], + category: "database", + tags: ["sql", "database", "query", "postgres", "postgresql"], + transport: "stdio", + version: "latest", + popularity: 90, + verified: true, + envVars: ["POSTGRES_CONNECTION_STRING"], + installHint: "Set POSTGRES_CONNECTION_STRING environment variable", + updatedAt: "2024-12-01", + }, + { + id: "github", + name: "GitHub", + description: "Interact with GitHub repositories, issues, and pull requests", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-github", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + category: "dev-tools", + tags: ["github", "git", "repository", "issues", "pr"], + transport: "stdio", + version: "latest", + popularity: 92, + verified: true, + envVars: ["GITHUB_PERSONAL_ACCESS_TOKEN"], + installHint: "Set GITHUB_PERSONAL_ACCESS_TOKEN environment variable", + updatedAt: "2024-12-01", + }, + { + id: "gitlab", + name: "GitLab", + description: "Interact with GitLab repositories and CI/CD", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-gitlab", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-gitlab"], + category: "dev-tools", + tags: ["gitlab", "git", "repository", "ci", "cd"], + transport: "stdio", + version: "latest", + popularity: 75, + verified: true, + envVars: ["GITLAB_PERSONAL_ACCESS_TOKEN", "GITLAB_API_URL"], + installHint: "Set GITLAB_PERSONAL_ACCESS_TOKEN and optionally GITLAB_API_URL", + updatedAt: "2024-12-01", + }, + { + id: "slack", + name: "Slack", + description: "Send messages and interact with Slack workspaces", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-slack", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-slack"], + category: "communication", + tags: ["slack", "messaging", "chat", "team"], + transport: "stdio", + version: "latest", + popularity: 80, + verified: true, + envVars: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"], + installHint: "Set SLACK_BOT_TOKEN and SLACK_TEAM_ID environment variables", + updatedAt: "2024-12-01", + }, + { + id: "google-drive", + name: "Google Drive", + description: "Access and manage files in Google Drive", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-gdrive", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-gdrive"], + category: "cloud", + tags: ["google", "drive", "cloud", "storage", "files"], + transport: "stdio", + version: "latest", + popularity: 78, + verified: true, + installHint: "Requires Google OAuth credentials setup", + updatedAt: "2024-12-01", + }, + { + id: "memory", + name: "Memory", + description: "Persistent memory and knowledge graph for context", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-memory", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"], + category: "ai", + tags: ["memory", "knowledge", "graph", "context", "persistent"], + transport: "stdio", + version: "latest", + popularity: 85, + verified: true, + updatedAt: "2024-12-01", + }, + { + id: "brave-search", + name: "Brave Search", + description: "Search the web using Brave Search API", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-brave-search", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-brave-search"], + category: "web", + tags: ["search", "web", "brave", "internet"], + transport: "stdio", + version: "latest", + popularity: 82, + verified: true, + envVars: ["BRAVE_API_KEY"], + installHint: "Set BRAVE_API_KEY environment variable", + updatedAt: "2024-12-01", + }, + { + id: "puppeteer", + name: "Puppeteer", + description: "Browser automation and web scraping with Puppeteer", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-puppeteer", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-puppeteer"], + category: "web", + tags: ["browser", "automation", "scraping", "puppeteer", "chrome"], + transport: "stdio", + version: "latest", + popularity: 76, + verified: true, + updatedAt: "2024-12-01", + }, + { + id: "fetch", + name: "Fetch", + description: "Make HTTP requests and fetch web content", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-fetch", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-fetch"], + category: "web", + tags: ["http", "fetch", "api", "request", "web"], + transport: "stdio", + version: "latest", + popularity: 88, + verified: true, + updatedAt: "2024-12-01", + }, + { + id: "sequential-thinking", + name: "Sequential Thinking", + description: "Step-by-step reasoning and problem-solving", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-sequential-thinking", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-sequential-thinking"], + category: "ai", + tags: ["thinking", "reasoning", "chain-of-thought", "problem-solving"], + transport: "stdio", + version: "latest", + popularity: 70, + verified: true, + updatedAt: "2024-12-01", + }, + { + id: "everart", + name: "EverArt", + description: "AI image generation with EverArt", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-everart", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-everart"], + category: "ai", + tags: ["image", "generation", "ai", "art", "creative"], + transport: "stdio", + version: "latest", + popularity: 65, + verified: true, + envVars: ["EVERART_API_KEY"], + installHint: "Set EVERART_API_KEY environment variable", + updatedAt: "2024-12-01", + }, + { + id: "sentry", + name: "Sentry", + description: "Access Sentry error tracking and monitoring", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-sentry", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-sentry"], + category: "dev-tools", + tags: ["sentry", "errors", "monitoring", "debugging"], + transport: "stdio", + version: "latest", + popularity: 72, + verified: true, + envVars: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"], + installHint: "Set SENTRY_AUTH_TOKEN and SENTRY_ORG environment variables", + updatedAt: "2024-12-01", + }, + { + id: "aws-kb-retrieval", + name: "AWS Knowledge Base", + description: "Retrieve information from AWS Bedrock Knowledge Bases", + author: "Anthropic", + repository: "https://github.com/modelcontextprotocol/servers", + package: "@modelcontextprotocol/server-aws-kb-retrieval", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-aws-kb-retrieval"], + category: "cloud", + tags: ["aws", "bedrock", "knowledge-base", "retrieval", "rag"], + transport: "stdio", + version: "latest", + popularity: 68, + verified: true, + envVars: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"], + installHint: "Set AWS credentials environment variables", + updatedAt: "2024-12-01", + }, +]; + +/** + * Error messages + */ +export const MCP_REGISTRY_ERRORS = { + FETCH_FAILED: "Failed to fetch MCP registry", + PARSE_FAILED: "Failed to parse registry data", + NOT_FOUND: "No servers found matching your search", + INSTALL_FAILED: "Failed to install MCP server", + ALREADY_INSTALLED: "Server is already installed", +} as const; + +/** + * Success messages + */ +export const MCP_REGISTRY_SUCCESS = { + INSTALLED: "MCP server installed successfully", + CONNECTED: "MCP server connected", + CACHE_UPDATED: "Registry cache updated", +} as const; diff --git a/src/services/mcp/index.ts b/src/services/mcp/index.ts index 950798d..976152b 100644 --- a/src/services/mcp/index.ts +++ b/src/services/mcp/index.ts @@ -35,3 +35,28 @@ export type { MCPTransportType, MCPManagerState, } from "@/types/mcp"; + +// Registry exports +export { + getAllServers, + getCuratedServers, + searchServers, + getServerById, + getServersByCategory, + isServerInstalled, + installServer, + installServerById, + getPopularServers, + getVerifiedServers, + getCategoriesWithCounts, + refreshRegistry, + clearRegistryCache, +} from "@services/mcp/registry"; + +export type { + MCPRegistryServer, + MCPSearchResult, + MCPSearchOptions, + MCPInstallResult, + MCPServerCategory, +} from "@/types/mcp-registry"; diff --git a/src/services/mcp/registry.ts b/src/services/mcp/registry.ts new file mode 100644 index 0000000..58f6dfa --- /dev/null +++ b/src/services/mcp/registry.ts @@ -0,0 +1,457 @@ +/** + * MCP Registry Service + * + * Service for discovering, searching, and installing MCP servers + */ + +import { homedir } from "os"; +import { join } from "path"; +import type { + MCPRegistryServer, + MCPSearchResult, + MCPSearchOptions, + MCPRegistryCache, + MCPInstallResult, + MCPServerCategory, +} from "@/types/mcp-registry"; +import { + MCP_CURATED_SERVERS, + MCP_REGISTRY_CACHE, + MCP_REGISTRY_SOURCES, + MCP_REGISTRY_ERRORS, + MCP_REGISTRY_SUCCESS, + MCP_SEARCH_DEFAULTS, +} from "@constants/mcp-registry"; +import { addServer, connectServer, getServerInstances } from "./manager"; + +/** + * In-memory cache for registry data + */ +let registryCache: MCPRegistryCache | null = null; + +/** + * Get cache file path + */ +const getCacheFilePath = (): string => { + return join(homedir(), ".codetyper", MCP_REGISTRY_CACHE.FILE_NAME); +}; + +/** + * Load cache from disk + */ +const loadCache = async (): Promise => { + try { + const cachePath = getCacheFilePath(); + const file = Bun.file(cachePath); + if (await file.exists()) { + const data = await file.json(); + return data as MCPRegistryCache; + } + } catch { + // Cache doesn't exist or is invalid + } + return null; +}; + +/** + * Save cache to disk + */ +const saveCache = async (cache: MCPRegistryCache): Promise => { + try { + const cachePath = getCacheFilePath(); + await Bun.write(cachePath, JSON.stringify(cache, null, 2)); + } catch { + // Ignore cache write errors + } +}; + +/** + * Check if cache is valid (not expired) + */ +const isCacheValid = (cache: MCPRegistryCache): boolean => { + const now = Date.now(); + return now - cache.updatedAt < MCP_REGISTRY_CACHE.DURATION_MS; +}; + +/** + * Fetch servers from Smithery registry + */ +const fetchFromSmithery = async (): Promise => { + try { + const response = await fetch(MCP_REGISTRY_SOURCES.SMITHERY); + if (!response.ok) { + return []; + } + const data = await response.json(); + + // Transform Smithery format to our format + if (Array.isArray(data)) { + return data.map((server: Record) => ({ + id: String(server.name || server.id || ""), + name: String(server.displayName || server.name || ""), + description: String(server.description || ""), + author: String(server.author || server.vendor || "Community"), + repository: String(server.homepage || server.repository || ""), + package: String(server.qualifiedName || server.package || ""), + command: "npx", + args: ["-y", String(server.qualifiedName || server.package || "")], + category: mapCategory(String(server.category || "other")), + tags: Array.isArray(server.tags) ? server.tags.map(String) : [], + transport: "stdio" as const, + version: String(server.version || "latest"), + popularity: Number(server.downloads || server.useCount || 0), + verified: Boolean(server.verified || server.isOfficial), + installHint: String(server.installHint || ""), + envVars: Array.isArray(server.environmentVariables) + ? server.environmentVariables.map(String) + : undefined, + updatedAt: String(server.updatedAt || new Date().toISOString()), + })); + } + return []; + } catch { + return []; + } +}; + +/** + * Map external category to our category type + */ +const mapCategory = (category: string): MCPServerCategory => { + const categoryMap: Record = { + database: "database", + databases: "database", + db: "database", + filesystem: "filesystem", + files: "filesystem", + file: "filesystem", + web: "web", + browser: "web", + http: "web", + ai: "ai", + ml: "ai", + "machine-learning": "ai", + "dev-tools": "dev-tools", + developer: "dev-tools", + development: "dev-tools", + tools: "dev-tools", + productivity: "productivity", + communication: "communication", + chat: "communication", + messaging: "communication", + cloud: "cloud", + aws: "cloud", + gcp: "cloud", + azure: "cloud", + security: "security", + }; + + const normalized = category.toLowerCase().trim(); + return categoryMap[normalized] || "other"; +}; + +/** + * Get all servers (curated + external) + */ +export const getAllServers = async ( + forceRefresh = false +): Promise => { + // Check in-memory cache first + if (!forceRefresh && registryCache && isCacheValid(registryCache)) { + return registryCache.servers; + } + + // Check disk cache + if (!forceRefresh) { + const diskCache = await loadCache(); + if (diskCache && isCacheValid(diskCache)) { + registryCache = diskCache; + return diskCache.servers; + } + } + + // Fetch from external sources + const externalServers = await fetchFromSmithery(); + + // Merge curated servers with external, curated takes precedence + const curatedIds = new Set(MCP_CURATED_SERVERS.map((s) => s.id)); + const filteredExternal = externalServers.filter((s) => !curatedIds.has(s.id)); + + const allServers = [...MCP_CURATED_SERVERS, ...filteredExternal]; + + // Update cache + registryCache = { + servers: allServers, + updatedAt: Date.now(), + source: MCP_REGISTRY_SOURCES.SMITHERY, + }; + + await saveCache(registryCache); + + return allServers; +}; + +/** + * Get curated servers only (no network) + */ +export const getCuratedServers = (): MCPRegistryServer[] => { + return MCP_CURATED_SERVERS; +}; + +/** + * Search for MCP servers + */ +export const searchServers = async ( + options: MCPSearchOptions = {} +): Promise => { + const { + query = "", + category, + tags, + verifiedOnly = false, + sortBy = MCP_SEARCH_DEFAULTS.SORT_BY, + limit = MCP_SEARCH_DEFAULTS.LIMIT, + offset = 0, + } = options; + + const allServers = await getAllServers(); + + let filtered = allServers; + + // Filter by query + if (query) { + const lowerQuery = query.toLowerCase(); + filtered = filtered.filter((server) => { + const searchableText = [ + server.name, + server.description, + server.author, + ...server.tags, + ].join(" ").toLowerCase(); + + return searchableText.includes(lowerQuery); + }); + } + + // Filter by category + if (category) { + filtered = filtered.filter((server) => server.category === category); + } + + // Filter by tags + if (tags && tags.length > 0) { + filtered = filtered.filter((server) => + tags.some((tag) => + server.tags.some((serverTag) => + serverTag.toLowerCase().includes(tag.toLowerCase()) + ) + ) + ); + } + + // Filter verified only + if (verifiedOnly) { + filtered = filtered.filter((server) => server.verified); + } + + // Sort + const sortFunctions: Record number> = { + popularity: (a, b) => b.popularity - a.popularity, + name: (a, b) => a.name.localeCompare(b.name), + updated: (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + }; + + filtered.sort(sortFunctions[sortBy] || sortFunctions.popularity); + + // Paginate + const total = filtered.length; + const paginated = filtered.slice(offset, offset + limit); + + return { + servers: paginated, + total, + query, + category, + }; +}; + +/** + * Get server by ID + */ +export const getServerById = async ( + id: string +): Promise => { + const allServers = await getAllServers(); + return allServers.find((server) => server.id === id); +}; + +/** + * Get servers by category + */ +export const getServersByCategory = async ( + category: MCPServerCategory +): Promise => { + const allServers = await getAllServers(); + return allServers.filter((server) => server.category === category); +}; + +/** + * Check if a server is already installed + */ +export const isServerInstalled = (serverId: string): boolean => { + const instances = getServerInstances(); + return instances.some((instance) => + instance.config.name === serverId || + instance.config.name.toLowerCase() === serverId.toLowerCase() + ); +}; + +/** + * Install an MCP server from the registry + */ +export const installServer = async ( + server: MCPRegistryServer, + options: { + global?: boolean; + connect?: boolean; + customArgs?: string[]; + } = {} +): Promise => { + const { global = false, connect = true, customArgs } = options; + + // Check if already installed + if (isServerInstalled(server.id)) { + return { + success: false, + serverName: server.id, + error: MCP_REGISTRY_ERRORS.ALREADY_INSTALLED, + connected: false, + }; + } + + try { + // Add server to configuration + await addServer( + { + name: server.id, + command: server.command, + args: customArgs || server.args, + transport: server.transport, + enabled: true, + }, + global + ); + + let connected = false; + + // Connect if requested + if (connect) { + try { + await connectServer(server.id); + connected = true; + } catch { + // Server added but connection failed + } + } + + return { + success: true, + serverName: server.id, + connected, + }; + } catch (error) { + return { + success: false, + serverName: server.id, + error: error instanceof Error ? error.message : MCP_REGISTRY_ERRORS.INSTALL_FAILED, + connected: false, + }; + } +}; + +/** + * Install server by ID + */ +export const installServerById = async ( + serverId: string, + options: { + global?: boolean; + connect?: boolean; + customArgs?: string[]; + } = {} +): Promise => { + const server = await getServerById(serverId); + + if (!server) { + return { + success: false, + serverName: serverId, + error: MCP_REGISTRY_ERRORS.NOT_FOUND, + connected: false, + }; + } + + return installServer(server, options); +}; + +/** + * Get popular servers + */ +export const getPopularServers = async ( + limit = 10 +): Promise => { + const allServers = await getAllServers(); + return allServers + .sort((a, b) => b.popularity - a.popularity) + .slice(0, limit); +}; + +/** + * Get verified servers + */ +export const getVerifiedServers = async (): Promise => { + const allServers = await getAllServers(); + return allServers.filter((server) => server.verified); +}; + +/** + * Get all categories with counts + */ +export const getCategoriesWithCounts = async (): Promise< + Array<{ category: MCPServerCategory; count: number }> +> => { + const allServers = await getAllServers(); + const counts = new Map(); + + for (const server of allServers) { + const current = counts.get(server.category) || 0; + counts.set(server.category, current + 1); + } + + return Array.from(counts.entries()) + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count); +}; + +/** + * Refresh registry cache + */ +export const refreshRegistry = async (): Promise => { + await getAllServers(true); +}; + +/** + * Clear registry cache + */ +export const clearRegistryCache = async (): Promise => { + registryCache = null; + try { + const cachePath = getCacheFilePath(); + const file = Bun.file(cachePath); + if (await file.exists()) { + await Bun.write(cachePath, ""); + } + } catch { + // Ignore + } +}; diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 4f92ca2..679fe9a 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -21,6 +21,7 @@ import { AgentSelect, ThemeSelect, MCPSelect, + MCPBrowser, TodoPanel, FilePicker, HomeContent, @@ -130,6 +131,7 @@ export function App({ const isAgentSelectOpen = mode === "agent_select"; const isThemeSelectOpen = mode === "theme_select"; const isMCPSelectOpen = mode === "mcp_select"; + const isMCPBrowserOpen = mode === "mcp_browse"; const isLearningPromptOpen = mode === "learning_prompt"; // Theme colors @@ -296,6 +298,11 @@ export function App({ setMode("idle"); }, [setMode]); + // Handle MCP browser close + const handleMCPBrowserClose = useCallback(() => { + setMode("idle"); + }, [setMode]); + // Handle file selection from picker const handleFileSelect = useCallback( (path: string): void => { @@ -400,6 +407,7 @@ export function App({ isAgentSelectOpen || isThemeSelectOpen || isMCPSelectOpen || + isMCPBrowserOpen || isLearningPromptOpen ) { closeCommandMenu(); @@ -732,6 +740,14 @@ export function App({ /> )} + {/* MCP Browser Overlay */} + {isMCPBrowserOpen && ( + + )} + {/* File Picker Overlay */} {filePickerOpen && ( void; + onInstalled?: (serverName: string) => void; + isActive?: boolean; +} + +type BrowserMode = "browse" | "search" | "category" | "detail" | "installing"; + +interface CategoryCount { + category: MCPServerCategory; + count: number; +} + +const MAX_VISIBLE = 8; + +const STATUS_COLORS = { + verified: "green", + installed: "cyan", + popular: "yellow", + default: "white", +} as const; + +export function MCPBrowser({ + onClose, + onInstalled, + isActive = true, +}: MCPBrowserProps): React.ReactElement { + const [mode, setMode] = useState("browse"); + const [servers, setServers] = useState([]); + const [categories, setCategories] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(null); + const [selectedServer, setSelectedServer] = useState(null); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(null); + const [messageType, setMessageType] = useState<"success" | "error" | "info">("info"); + + // Load initial data + const loadData = useCallback(async () => { + setLoading(true); + try { + const curatedServers = getCuratedServers(); + setServers(curatedServers); + const cats = await getCategoriesWithCounts(); + setCategories(cats); + } catch { + setServers(getCuratedServers()); + } + setLoading(false); + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + // Search handler + const handleSearch = useCallback(async (query: string) => { + setLoading(true); + try { + const result = await searchServers({ + query, + category: selectedCategory || undefined, + }); + setServers(result.servers); + } catch { + setMessage("Search failed"); + setMessageType("error"); + } + setLoading(false); + }, [selectedCategory]); + + // Category filter handler + const handleCategorySelect = useCallback(async (category: MCPServerCategory | null) => { + setSelectedCategory(category); + setLoading(true); + try { + const result = await searchServers({ + query: searchQuery, + category: category || undefined, + }); + setServers(result.servers); + } catch { + setMessage("Failed to filter"); + setMessageType("error"); + } + setLoading(false); + setMode("browse"); + setSelectedIndex(0); + setScrollOffset(0); + }, [searchQuery]); + + // Install handler + const handleInstall = useCallback(async (server: MCPRegistryServer) => { + setMode("installing"); + setMessage(`Installing ${server.name}...`); + setMessageType("info"); + + try { + const result = await installServer(server, { connect: true }); + if (result.success) { + setMessage(`Installed ${server.name}${result.connected ? " and connected" : ""}`); + setMessageType("success"); + onInstalled?.(server.id); + } else { + setMessage(result.error || "Installation failed"); + setMessageType("error"); + } + } catch (error) { + setMessage(error instanceof Error ? error.message : "Installation failed"); + setMessageType("error"); + } + + setMode("browse"); + setTimeout(() => setMessage(null), 3000); + }, [onInstalled]); + + // Filtered and displayed items + const displayItems = useMemo(() => { + if (mode === "category") { + return categories.map((cat) => ({ + id: cat.category, + label: `${MCP_CATEGORY_ICONS[cat.category]} ${MCP_CATEGORY_LABELS[cat.category]}`, + count: cat.count, + })); + } + return servers; + }, [mode, servers, categories]); + + // Visible window + const visibleItems = useMemo(() => { + if (mode === "category") { + return displayItems.slice(scrollOffset, scrollOffset + MAX_VISIBLE); + } + return servers.slice(scrollOffset, scrollOffset + MAX_VISIBLE); + }, [displayItems, servers, scrollOffset, mode]); + + // Input handling + useInput( + (input, key) => { + if (!isActive) return; + + // Clear message on any key + if (message && mode !== "installing") { + setMessage(null); + } + + // Escape handling + if (key.escape) { + if (mode === "detail") { + setMode("browse"); + setSelectedServer(null); + } else if (mode === "search") { + setMode("browse"); + setSearchQuery(""); + } else if (mode === "category") { + setMode("browse"); + } else { + onClose(); + } + return; + } + + // Installing mode - ignore input + if (mode === "installing") return; + + // Search mode - typing + if (mode === "search") { + if (key.return) { + handleSearch(searchQuery); + setMode("browse"); + } else if (key.backspace || key.delete) { + setSearchQuery((prev) => prev.slice(0, -1)); + } else if (input && !key.ctrl && !key.meta) { + setSearchQuery((prev) => prev + input); + } + return; + } + + // Detail mode + if (mode === "detail" && selectedServer) { + if (key.return || input === "i") { + if (!isServerInstalled(selectedServer.id)) { + handleInstall(selectedServer); + } else { + setMessage("Already installed"); + setMessageType("info"); + setTimeout(() => setMessage(null), 2000); + } + } + return; + } + + // Navigation + if (key.upArrow || input === "k") { + setSelectedIndex((prev) => { + const newIndex = Math.max(0, prev - 1); + if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } + return newIndex; + }); + } else if (key.downArrow || input === "j") { + const maxIndex = (mode === "category" ? categories.length : servers.length) - 1; + setSelectedIndex((prev) => { + const newIndex = Math.min(maxIndex, prev + 1); + if (newIndex >= scrollOffset + MAX_VISIBLE) { + setScrollOffset(newIndex - MAX_VISIBLE + 1); + } + return newIndex; + }); + } else if (key.return) { + // Select item + if (mode === "category") { + const cat = categories[selectedIndex]; + if (cat) { + handleCategorySelect(cat.category); + } + } else { + const server = servers[selectedIndex]; + if (server) { + setSelectedServer(server); + setMode("detail"); + } + } + } else if (input === "/") { + setMode("search"); + setSearchQuery(""); + } else if (input === "c") { + setMode("category"); + setSelectedIndex(0); + setScrollOffset(0); + } else if (input === "i" && mode === "browse") { + const server = servers[selectedIndex]; + if (server && !isServerInstalled(server.id)) { + handleInstall(server); + } + } else if (input === "r") { + loadData(); + } + }, + { isActive } + ); + + // Render loading + if (loading && servers.length === 0) { + return ( + + + + MCP Server Browser + + + + Loading servers... + + + ); + } + + // Render detail view + if (mode === "detail" && selectedServer) { + const installed = isServerInstalled(selectedServer.id); + return ( + + + + {selectedServer.name} + + {selectedServer.verified && ( + āœ“ + )} + + + + + {selectedServer.description} + + + + Author: + {selectedServer.author} + + + + Category: + + {MCP_CATEGORY_ICONS[selectedServer.category]}{" "} + {MCP_CATEGORY_LABELS[selectedServer.category]} + + + + + Package: + {selectedServer.package} + + + {selectedServer.envVars && selectedServer.envVars.length > 0 && ( + + Required env vars: + {selectedServer.envVars.map((envVar) => ( + + {" "}${envVar} + + ))} + + )} + + {selectedServer.installHint && ( + + + Note: {selectedServer.installHint} + + + )} + + + {message && ( + + + {message} + + + )} + + + {installed ? ( + Already installed + ) : ( + + [Enter/i] Install{" "} + [Esc] Back + + )} + + + ); + } + + // Render category view + if (mode === "category") { + return ( + + + + Select Category + + + + {/* All categories option */} + + + šŸ“‹ All Categories + + + + {visibleItems.map((item, index) => { + const actualIndex = index + scrollOffset; + const isSelected = actualIndex === selectedIndex; + const catItem = item as { id: string; label: string; count: number }; + + return ( + + + {catItem.label} ({catItem.count}) + + + ); + })} + + + + [↑↓] Navigate{" "} + [Enter] Select{" "} + [Esc] Back + + + + ); + } + + // Render search mode + if (mode === "search") { + return ( + + + + Search MCP Servers + + + + + / + {searchQuery} + ā–‹ + + + + + [Enter] Search{" "} + [Esc] Cancel + + + + ); + } + + // Render browse mode + const hasMore = servers.length > scrollOffset + MAX_VISIBLE; + const hasLess = scrollOffset > 0; + + return ( + + + + MCP Server Browser + + + {servers.length} servers + {selectedCategory && ` • ${MCP_CATEGORY_LABELS[selectedCategory]}`} + + + + {hasLess && ( + + ↑ more + + )} + + {visibleItems.map((server, index) => { + const actualIndex = index + scrollOffset; + const isSelected = actualIndex === selectedIndex; + const installed = isServerInstalled((server as MCPRegistryServer).id); + + return ( + + + + {(server as MCPRegistryServer).verified ? "āœ“ " : " "} + {(server as MCPRegistryServer).name} + + + + {installed ? ( + installed + ) : ( + + {MCP_CATEGORY_ICONS[(server as MCPRegistryServer).category]} + + )} + + + ); + })} + + {hasMore && ( + + ↓ more + + )} + + {message && ( + + + {message} + + + )} + + + + [/] Search{" "} + [c] Category{" "} + [i] Install{" "} + [Esc] Close + + + + ); +} + +export default MCPBrowser; diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index da2e149..d179d52 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -14,6 +14,7 @@ export { ModelSelect } from "@tui/components/ModelSelect"; export { AgentSelect } from "@tui/components/AgentSelect"; export { ThemeSelect } from "@tui/components/ThemeSelect"; export { MCPSelect } from "@tui/components/MCPSelect"; +export { MCPBrowser } from "@tui/components/MCPBrowser"; export { TodoPanel } from "@tui/components/TodoPanel"; export { LearningModal } from "@tui/components/LearningModal"; export { ImageAttachment, ImageAttachmentCompact } from "@tui/components/ImageAttachment"; diff --git a/src/types/mcp-registry.ts b/src/types/mcp-registry.ts new file mode 100644 index 0000000..117024b --- /dev/null +++ b/src/types/mcp-registry.ts @@ -0,0 +1,139 @@ +/** + * MCP Registry Types + * + * Types for MCP server discovery and search + */ + +/** + * MCP server category + */ +export type MCPServerCategory = + | "database" + | "filesystem" + | "web" + | "ai" + | "dev-tools" + | "productivity" + | "communication" + | "cloud" + | "security" + | "other"; + +/** + * MCP server transport type for registry + */ +export type MCPRegistryTransport = "stdio" | "sse" | "http"; + +/** + * MCP server entry from registry + */ +export interface MCPRegistryServer { + /** Unique identifier */ + id: string; + /** Display name */ + name: string; + /** Short description */ + description: string; + /** Author/maintainer */ + author: string; + /** Repository URL */ + repository: string; + /** NPM package or command */ + package: string; + /** Default command to run */ + command: string; + /** Default arguments */ + args: string[]; + /** Category */ + category: MCPServerCategory; + /** Tags for search */ + tags: string[]; + /** Transport type */ + transport: MCPRegistryTransport; + /** Version */ + version: string; + /** Downloads/popularity score */ + popularity: number; + /** Whether verified by maintainers */ + verified: boolean; + /** Installation instructions */ + installHint?: string; + /** Environment variables needed */ + envVars?: string[]; + /** Last updated timestamp */ + updatedAt: string; +} + +/** + * Search result from registry + */ +export interface MCPSearchResult { + /** Matching servers */ + servers: MCPRegistryServer[]; + /** Total count */ + total: number; + /** Search query */ + query: string; + /** Category filter applied */ + category?: MCPServerCategory; +} + +/** + * Registry source configuration + */ +export interface MCPRegistrySource { + /** Source name */ + name: string; + /** API URL or file path */ + url: string; + /** Whether enabled */ + enabled: boolean; + /** Last fetched timestamp */ + lastFetched?: number; +} + +/** + * Cached registry data + */ +export interface MCPRegistryCache { + /** All servers from registry */ + servers: MCPRegistryServer[]; + /** Last updated timestamp */ + updatedAt: number; + /** Source URL */ + source: string; +} + +/** + * Search options + */ +export interface MCPSearchOptions { + /** Search query */ + query?: string; + /** Filter by category */ + category?: MCPServerCategory; + /** Filter by tags */ + tags?: string[]; + /** Only verified servers */ + verifiedOnly?: boolean; + /** Sort by */ + sortBy?: "popularity" | "name" | "updated"; + /** Limit results */ + limit?: number; + /** Offset for pagination */ + offset?: number; +} + +/** + * Installation result + */ +export interface MCPInstallResult { + /** Whether successful */ + success: boolean; + /** Server name */ + serverName: string; + /** Error message if failed */ + error?: string; + /** Whether connected after install */ + connected: boolean; +} diff --git a/src/types/tui.ts b/src/types/tui.ts index 5eb8587..425c3d4 100644 --- a/src/types/tui.ts +++ b/src/types/tui.ts @@ -23,6 +23,7 @@ export type AppMode = | "mode_select" | "mcp_select" | "mcp_add" + | "mcp_browse" | "file_picker" | "provider_select" | "learning_prompt"