Adding mcp searcher

This commit is contained in:
2026-01-31 22:35:24 -05:00
parent f3b39ec8a1
commit 1491840a60
11 changed files with 1786 additions and 4 deletions

View File

@@ -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 <query> # 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

View File

@@ -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 <query>`
- Filter by category (database, web, AI, dev-tools, etc.)
- View server details and required environment variables
- One-click install with `/mcp install <id>`
- 15+ curated verified servers from Anthropic
- Registry integration with Smithery
- **Reasoning System**: Advanced agent orchestration
- Memory selection for context optimization

View File

@@ -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<void> => {
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<void> => {
const handleAdd = async (_args: string[]): Promise<void> => {
appStore.setMode("mcp_add");
};
/**
* Search for MCP servers
*/
const handleSearch = async (args: string[]): Promise<void> => {
const query = args.join(" ");
if (!query) {
console.log(chalk.yellow("\nUsage: /mcp search <query>"));
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<void> => {
appStore.setMode("mcp_browse");
};
/**
* Install an MCP server by ID
*/
const handleInstall = async (args: string[]): Promise<void> => {
const serverId = args[0];
if (!serverId) {
console.log(chalk.yellow("\nUsage: /mcp install <server-id>"));
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<void> => {
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<void> => {
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 <category> to filter by category"));
console.log();
} catch (error) {
console.log(chalk.red(`\nFailed to fetch categories: ${error}`));
console.log();
}
};

View File

@@ -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<MCPServerCategory, string> = {
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<MCPServerCategory, string> = {
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;

View File

@@ -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";

View File

@@ -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<MCPRegistryCache | null> => {
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<void> => {
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<MCPRegistryServer[]> => {
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<string, unknown>) => ({
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<string, MCPServerCategory> = {
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<MCPRegistryServer[]> => {
// 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<MCPSearchResult> => {
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<string, (a: MCPRegistryServer, b: MCPRegistryServer) => 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<MCPRegistryServer | undefined> => {
const allServers = await getAllServers();
return allServers.find((server) => server.id === id);
};
/**
* Get servers by category
*/
export const getServersByCategory = async (
category: MCPServerCategory
): Promise<MCPRegistryServer[]> => {
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<MCPInstallResult> => {
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<MCPInstallResult> => {
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<MCPRegistryServer[]> => {
const allServers = await getAllServers();
return allServers
.sort((a, b) => b.popularity - a.popularity)
.slice(0, limit);
};
/**
* Get verified servers
*/
export const getVerifiedServers = async (): Promise<MCPRegistryServer[]> => {
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<MCPServerCategory, number>();
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<void> => {
await getAllServers(true);
};
/**
* Clear registry cache
*/
export const clearRegistryCache = async (): Promise<void> => {
registryCache = null;
try {
const cachePath = getCacheFilePath();
const file = Bun.file(cachePath);
if (await file.exists()) {
await Bun.write(cachePath, "");
}
} catch {
// Ignore
}
};

View File

@@ -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 && (
<MCPBrowser
onClose={handleMCPBrowserClose}
isActive={isMCPBrowserOpen}
/>
)}
{/* File Picker Overlay */}
{filePickerOpen && (
<FilePicker

View File

@@ -0,0 +1,600 @@
/**
* MCPBrowser Component - Browse and search MCP servers from registry
*
* Allows users to discover, search, and install MCP servers
*/
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Box, Text, useInput } from "ink";
import {
searchServers,
getCuratedServers,
installServer,
isServerInstalled,
getCategoriesWithCounts,
} from "@services/mcp/index";
import type {
MCPRegistryServer,
MCPServerCategory,
} from "@/types/mcp-registry";
import {
MCP_CATEGORY_LABELS,
MCP_CATEGORY_ICONS,
} from "@constants/mcp-registry";
interface MCPBrowserProps {
onClose: () => 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<BrowserMode>("browse");
const [servers, setServers] = useState<MCPRegistryServer[]>([]);
const [categories, setCategories] = useState<CategoryCount[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<MCPServerCategory | null>(null);
const [selectedServer, setSelectedServer] = useState<MCPRegistryServer | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="center" marginY={1}>
<Text color="cyan" bold>
MCP Server Browser
</Text>
</Box>
<Box justifyContent="center" marginY={1}>
<Text color="gray">Loading servers...</Text>
</Box>
</Box>
);
}
// Render detail view
if (mode === "detail" && selectedServer) {
const installed = isServerInstalled(selectedServer.id);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="center" marginBottom={1}>
<Text color="cyan" bold>
{selectedServer.name}
</Text>
{selectedServer.verified && (
<Text color="green"> </Text>
)}
</Box>
<Box flexDirection="column" paddingX={1}>
<Text color="gray" wrap="wrap">
{selectedServer.description}
</Text>
<Box marginTop={1}>
<Text color="gray">Author: </Text>
<Text>{selectedServer.author}</Text>
</Box>
<Box>
<Text color="gray">Category: </Text>
<Text>
{MCP_CATEGORY_ICONS[selectedServer.category]}{" "}
{MCP_CATEGORY_LABELS[selectedServer.category]}
</Text>
</Box>
<Box>
<Text color="gray">Package: </Text>
<Text color="yellow">{selectedServer.package}</Text>
</Box>
{selectedServer.envVars && selectedServer.envVars.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Required env vars:</Text>
{selectedServer.envVars.map((envVar) => (
<Text key={envVar} color="magenta">
{" "}${envVar}
</Text>
))}
</Box>
)}
{selectedServer.installHint && (
<Box marginTop={1}>
<Text color="gray" wrap="wrap">
Note: {selectedServer.installHint}
</Text>
</Box>
)}
</Box>
{message && (
<Box justifyContent="center" marginTop={1}>
<Text
color={
messageType === "success"
? "green"
: messageType === "error"
? "red"
: "yellow"
}
>
{message}
</Text>
</Box>
)}
<Box justifyContent="center" marginTop={1} paddingTop={1} borderStyle="single" borderTop borderBottom={false} borderLeft={false} borderRight={false}>
{installed ? (
<Text color="cyan">Already installed</Text>
) : (
<Text>
<Text color="green">[Enter/i]</Text> Install{" "}
<Text color="gray">[Esc]</Text> Back
</Text>
)}
</Box>
</Box>
);
}
// Render category view
if (mode === "category") {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="center" marginBottom={1}>
<Text color="cyan" bold>
Select Category
</Text>
</Box>
{/* All categories option */}
<Box
paddingX={1}
backgroundColor={selectedIndex === -1 ? "cyan" : undefined}
>
<Text
color={selectedIndex === -1 ? "black" : "white"}
bold={selectedIndex === -1}
>
📋 All Categories
</Text>
</Box>
{visibleItems.map((item, index) => {
const actualIndex = index + scrollOffset;
const isSelected = actualIndex === selectedIndex;
const catItem = item as { id: string; label: string; count: number };
return (
<Box
key={catItem.id}
paddingX={1}
backgroundColor={isSelected ? "cyan" : undefined}
>
<Text
color={isSelected ? "black" : "white"}
bold={isSelected}
>
{catItem.label} ({catItem.count})
</Text>
</Box>
);
})}
<Box
justifyContent="center"
marginTop={1}
paddingTop={1}
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
>
<Text color="gray">
<Text color="cyan">[]</Text> Navigate{" "}
<Text color="cyan">[Enter]</Text> Select{" "}
<Text color="cyan">[Esc]</Text> Back
</Text>
</Box>
</Box>
);
}
// Render search mode
if (mode === "search") {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="center" marginBottom={1}>
<Text color="cyan" bold>
Search MCP Servers
</Text>
</Box>
<Box paddingX={1}>
<Text color="gray">/ </Text>
<Text>{searchQuery}</Text>
<Text color="cyan"></Text>
</Box>
<Box
justifyContent="center"
marginTop={1}
paddingTop={1}
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
>
<Text color="gray">
<Text color="cyan">[Enter]</Text> Search{" "}
<Text color="cyan">[Esc]</Text> Cancel
</Text>
</Box>
</Box>
);
}
// Render browse mode
const hasMore = servers.length > scrollOffset + MAX_VISIBLE;
const hasLess = scrollOffset > 0;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={1}
width={60}
>
<Box justifyContent="space-between" marginBottom={1}>
<Text color="cyan" bold>
MCP Server Browser
</Text>
<Text color="gray">
{servers.length} servers
{selectedCategory && `${MCP_CATEGORY_LABELS[selectedCategory]}`}
</Text>
</Box>
{hasLess && (
<Box justifyContent="center">
<Text color="gray"> more</Text>
</Box>
)}
{visibleItems.map((server, index) => {
const actualIndex = index + scrollOffset;
const isSelected = actualIndex === selectedIndex;
const installed = isServerInstalled((server as MCPRegistryServer).id);
return (
<Box
key={(server as MCPRegistryServer).id}
paddingX={1}
backgroundColor={isSelected ? "cyan" : undefined}
>
<Box width={45}>
<Text
color={isSelected ? "black" : installed ? "cyan" : "white"}
bold={isSelected}
>
{(server as MCPRegistryServer).verified ? "✓ " : " "}
{(server as MCPRegistryServer).name}
</Text>
</Box>
<Box width={10} justifyContent="flex-end">
{installed ? (
<Text color={isSelected ? "black" : "cyan"}>installed</Text>
) : (
<Text color={isSelected ? "black" : "gray"}>
{MCP_CATEGORY_ICONS[(server as MCPRegistryServer).category]}
</Text>
)}
</Box>
</Box>
);
})}
{hasMore && (
<Box justifyContent="center">
<Text color="gray"> more</Text>
</Box>
)}
{message && (
<Box justifyContent="center" marginTop={1}>
<Text
color={
messageType === "success"
? "green"
: messageType === "error"
? "red"
: "yellow"
}
>
{message}
</Text>
</Box>
)}
<Box
justifyContent="center"
marginTop={1}
paddingTop={1}
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
>
<Text color="gray">
<Text color="cyan">[/]</Text> Search{" "}
<Text color="cyan">[c]</Text> Category{" "}
<Text color="cyan">[i]</Text> Install{" "}
<Text color="cyan">[Esc]</Text> Close
</Text>
</Box>
</Box>
);
}
export default MCPBrowser;

View File

@@ -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";

139
src/types/mcp-registry.ts Normal file
View File

@@ -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;
}

View File

@@ -23,6 +23,7 @@ export type AppMode =
| "mode_select"
| "mcp_select"
| "mcp_add"
| "mcp_browse"
| "file_picker"
| "provider_select"
| "learning_prompt"