From f0c2860decb9394286829680c36d63b3689b9d73 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 28 Dec 2025 14:51:49 +0100 Subject: [PATCH] feat: add MCP server testing and tool listing functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MCPTestService for testing MCP server connections - Support stdio, SSE, and HTTP transport types - Implement workaround for SSE headers bug (SDK Issue #436) - Create API routes for /api/mcp/test and /api/mcp/tools - Add API client methods for MCP operations - Create MCPToolsList component with collapsible schema display - Add Test button to MCP servers section with status indicators - Add Headers field for HTTP/SSE servers - Add Environment Variables field for stdio servers - Fix text overflow in tools list display 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/index.ts | 4 + apps/server/src/routes/mcp/common.ts | 20 + apps/server/src/routes/mcp/index.ts | 36 ++ .../src/routes/mcp/routes/list-tools.ts | 67 ++++ .../src/routes/mcp/routes/test-server.ts | 62 +++ apps/server/src/services/mcp-test-service.ts | 208 ++++++++++ .../mcp-servers/mcp-servers-section.tsx | 357 +++++++++++++++--- .../mcp-servers/mcp-tools-list.tsx | 117 ++++++ apps/ui/src/lib/http-api-client.ts | 61 +++ libs/types/src/settings.ts | 2 + 10 files changed, 873 insertions(+), 61 deletions(-) create mode 100644 apps/server/src/routes/mcp/common.ts create mode 100644 apps/server/src/routes/mcp/index.ts create mode 100644 apps/server/src/routes/mcp/routes/list-tools.ts create mode 100644 apps/server/src/routes/mcp/routes/test-server.ts create mode 100644 apps/server/src/services/mcp-test-service.ts create mode 100644 apps/ui/src/components/views/settings-view/mcp-servers/mcp-tools-list.tsx diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 188e2883..6a101157 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -50,6 +50,8 @@ import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; +import { createMCPRoutes } from './routes/mcp/index.js'; +import { MCPTestService } from './services/mcp-test-service.js'; // Load environment variables dotenv.config(); @@ -119,6 +121,7 @@ const agentService = new AgentService(DATA_DIR, events, settingsService); const featureLoader = new FeatureLoader(); const autoModeService = new AutoModeService(events, settingsService); const claudeUsageService = new ClaudeUsageService(); +const mcpTestService = new MCPTestService(settingsService); // Initialize services (async () => { @@ -162,6 +165,7 @@ app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); +app.use('/api/mcp', createMCPRoutes(mcpTestService)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/mcp/common.ts b/apps/server/src/routes/mcp/common.ts new file mode 100644 index 00000000..5da4789c --- /dev/null +++ b/apps/server/src/routes/mcp/common.ts @@ -0,0 +1,20 @@ +/** + * Common utilities for MCP routes + */ + +/** + * Extract error message from unknown error + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +/** + * Log error with prefix + */ +export function logError(error: unknown, message: string): void { + console.error(`[MCP] ${message}:`, error); +} diff --git a/apps/server/src/routes/mcp/index.ts b/apps/server/src/routes/mcp/index.ts new file mode 100644 index 00000000..2c3a023f --- /dev/null +++ b/apps/server/src/routes/mcp/index.ts @@ -0,0 +1,36 @@ +/** + * MCP routes - HTTP API for testing MCP servers + * + * Provides endpoints for: + * - Testing MCP server connections + * - Listing available tools from MCP servers + * + * Mounted at /api/mcp in the main server. + */ + +import { Router } from 'express'; +import type { MCPTestService } from '../../services/mcp-test-service.js'; +import { createTestServerHandler } from './routes/test-server.js'; +import { createListToolsHandler } from './routes/list-tools.js'; + +/** + * Create MCP router with all endpoints + * + * Endpoints: + * - POST /test - Test MCP server connection + * - POST /tools - List tools from MCP server + * + * @param mcpTestService - Instance of MCPTestService for testing connections + * @returns Express Router configured with all MCP endpoints + */ +export function createMCPRoutes(mcpTestService: MCPTestService): Router { + const router = Router(); + + // Test MCP server connection + router.post('/test', createTestServerHandler(mcpTestService)); + + // List tools from MCP server + router.post('/tools', createListToolsHandler(mcpTestService)); + + return router; +} diff --git a/apps/server/src/routes/mcp/routes/list-tools.ts b/apps/server/src/routes/mcp/routes/list-tools.ts new file mode 100644 index 00000000..ac1d0e84 --- /dev/null +++ b/apps/server/src/routes/mcp/routes/list-tools.ts @@ -0,0 +1,67 @@ +/** + * POST /api/mcp/tools - List tools for an MCP server + * + * Lists available tools for an MCP server. + * Similar to test but focused on tool discovery. + * + * Request body: + * { serverId: string } - Get tools by server ID from settings + * OR { serverConfig: MCPServerConfig } - Get tools with provided config + * + * Response: { success: boolean, tools?: MCPToolInfo[], error?: string } + */ + +import type { Request, Response } from 'express'; +import type { MCPTestService } from '../../../services/mcp-test-service.js'; +import type { MCPServerConfig } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +interface ListToolsRequest { + serverId?: string; + serverConfig?: MCPServerConfig; +} + +/** + * Create handler factory for POST /api/mcp/tools + */ +export function createListToolsHandler(mcpTestService: MCPTestService) { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body as ListToolsRequest; + + if (!body.serverId && !body.serverConfig) { + res.status(400).json({ + success: false, + error: 'Either serverId or serverConfig is required', + }); + return; + } + + let result; + if (body.serverId) { + result = await mcpTestService.testServerById(body.serverId); + } else if (body.serverConfig) { + result = await mcpTestService.testServer(body.serverConfig); + } else { + res.status(400).json({ + success: false, + error: 'Invalid request', + }); + return; + } + + // Return only tool-related information + res.json({ + success: result.success, + tools: result.tools, + error: result.error, + }); + } catch (error) { + logError(error, 'List tools failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/mcp/routes/test-server.ts b/apps/server/src/routes/mcp/routes/test-server.ts new file mode 100644 index 00000000..8106dff1 --- /dev/null +++ b/apps/server/src/routes/mcp/routes/test-server.ts @@ -0,0 +1,62 @@ +/** + * POST /api/mcp/test - Test MCP server connection and list tools + * + * Tests connection to an MCP server and returns available tools. + * Accepts either a serverId to look up config, or a full server config. + * + * Request body: + * { serverId: string } - Test server by ID from settings + * OR { serverConfig: MCPServerConfig } - Test with provided config + * + * Response: { success: boolean, tools?: MCPToolInfo[], error?: string, connectionTime?: number } + */ + +import type { Request, Response } from 'express'; +import type { MCPTestService } from '../../../services/mcp-test-service.js'; +import type { MCPServerConfig } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +interface TestServerRequest { + serverId?: string; + serverConfig?: MCPServerConfig; +} + +/** + * Create handler factory for POST /api/mcp/test + */ +export function createTestServerHandler(mcpTestService: MCPTestService) { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body as TestServerRequest; + + if (!body.serverId && !body.serverConfig) { + res.status(400).json({ + success: false, + error: 'Either serverId or serverConfig is required', + }); + return; + } + + let result; + if (body.serverId) { + result = await mcpTestService.testServerById(body.serverId); + } else if (body.serverConfig) { + result = await mcpTestService.testServer(body.serverConfig); + } else { + res.status(400).json({ + success: false, + error: 'Invalid request', + }); + return; + } + + res.json(result); + } catch (error) { + logError(error, 'Test server failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/services/mcp-test-service.ts b/apps/server/src/services/mcp-test-service.ts new file mode 100644 index 00000000..d1662722 --- /dev/null +++ b/apps/server/src/services/mcp-test-service.ts @@ -0,0 +1,208 @@ +/** + * MCP Test Service + * + * Provides functionality to test MCP server connections and list available tools. + * Supports stdio, SSE, and HTTP transport types. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { MCPServerConfig, MCPToolInfo } from '@automaker/types'; +import type { SettingsService } from './settings-service.js'; + +const DEFAULT_TIMEOUT = 10000; // 10 seconds + +export interface MCPTestResult { + success: boolean; + tools?: MCPToolInfo[]; + error?: string; + connectionTime?: number; + serverInfo?: { + name?: string; + version?: string; + }; +} + +/** + * MCP Test Service for testing server connections and listing tools + */ +export class MCPTestService { + private settingsService: SettingsService; + + constructor(settingsService: SettingsService) { + this.settingsService = settingsService; + } + + /** + * Test connection to an MCP server and list its tools + */ + async testServer(serverConfig: MCPServerConfig): Promise { + const startTime = Date.now(); + let client: Client | null = null; + + try { + client = new Client({ + name: 'automaker-mcp-test', + version: '1.0.0', + }); + + // Create transport based on server type + const transport = await this.createTransport(serverConfig); + + // Connect with timeout + await Promise.race([ + client.connect(transport), + this.timeout(DEFAULT_TIMEOUT, 'Connection timeout'), + ]); + + // List tools with timeout + const toolsResult = await Promise.race([ + client.listTools(), + this.timeout<{ + tools: Array<{ + name: string; + description?: string; + inputSchema?: Record; + }>; + }>(DEFAULT_TIMEOUT, 'List tools timeout'), + ]); + + const connectionTime = Date.now() - startTime; + + // Convert tools to MCPToolInfo format + const tools: MCPToolInfo[] = (toolsResult.tools || []).map( + (tool: { name: string; description?: string; inputSchema?: Record }) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + enabled: true, + }) + ); + + return { + success: true, + tools, + connectionTime, + serverInfo: { + name: serverConfig.name, + version: undefined, // Could be extracted from server info if available + }, + }; + } catch (error) { + const connectionTime = Date.now() - startTime; + return { + success: false, + error: this.getErrorMessage(error), + connectionTime, + }; + } finally { + // Clean up client connection + if (client) { + try { + await client.close(); + } catch { + // Ignore cleanup errors + } + } + } + } + + /** + * Test server by ID (looks up config from settings) + */ + async testServerById(serverId: string): Promise { + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + const serverConfig = globalSettings.mcpServers?.find((s) => s.id === serverId); + + if (!serverConfig) { + return { + success: false, + error: `Server with ID "${serverId}" not found`, + }; + } + + return this.testServer(serverConfig); + } catch (error) { + return { + success: false, + error: this.getErrorMessage(error), + }; + } + } + + /** + * Create appropriate transport based on server type + */ + private async createTransport( + config: MCPServerConfig + ): Promise { + if (config.type === 'sse') { + if (!config.url) { + throw new Error('URL is required for SSE transport'); + } + // Use eventSourceInit workaround for SSE headers (SDK bug workaround) + // See: https://github.com/modelcontextprotocol/typescript-sdk/issues/436 + const headers = config.headers; + return new SSEClientTransport(new URL(config.url), { + requestInit: headers ? { headers } : undefined, + eventSourceInit: headers + ? { + fetch: (url: string | URL | Request, init?: RequestInit) => { + const fetchHeaders = new Headers(init?.headers || {}); + for (const [key, value] of Object.entries(headers)) { + fetchHeaders.set(key, value); + } + return fetch(url, { ...init, headers: fetchHeaders }); + }, + } + : undefined, + }); + } + + if (config.type === 'http') { + if (!config.url) { + throw new Error('URL is required for HTTP transport'); + } + return new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: config.headers + ? { + headers: config.headers, + } + : undefined, + }); + } + + // Default to stdio + if (!config.command) { + throw new Error('Command is required for stdio transport'); + } + + return new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env, + }); + } + + /** + * Create a timeout promise + */ + private timeout(ms: number, message: string): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(message)), ms); + }); + } + + /** + * Extract error message from unknown error + */ + private getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); + } +} diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx index 70863dd1..9db99cd0 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx +++ b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx @@ -19,6 +19,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Plug, Plus, @@ -29,12 +30,20 @@ import { FileJson, Download, RefreshCw, + PlayCircle, + CheckCircle2, + XCircle, + Loader2, + ChevronDown, + ChevronRight, } from 'lucide-react'; import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import type { MCPServerConfig } from '@automaker/types'; import { syncSettingsToServer, loadMCPServersFromServer } from '@/hooks/use-settings-migration'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { MCPToolsList, type MCPToolDisplay } from './mcp-tools-list'; type ServerType = 'stdio' | 'sse' | 'http'; @@ -45,6 +54,8 @@ interface ServerFormData { command: string; args: string; url: string; + headers: string; // JSON string for headers + env: string; // JSON string for env vars } const defaultFormData: ServerFormData = { @@ -54,8 +65,17 @@ const defaultFormData: ServerFormData = { command: '', args: '', url: '', + headers: '', + env: '', }; +interface ServerTestState { + status: 'idle' | 'testing' | 'success' | 'error'; + tools?: MCPToolDisplay[]; + error?: string; + connectionTime?: number; +} + export function MCPServersSection() { const { mcpServers, @@ -74,6 +94,8 @@ export function MCPServersSection() { const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const [importJson, setImportJson] = useState(''); const [isRefreshing, setIsRefreshing] = useState(false); + const [serverTestStates, setServerTestStates] = useState>({}); + const [expandedServers, setExpandedServers] = useState>(new Set()); // Auto-load MCP servers from settings file on mount useEffect(() => { @@ -98,6 +120,79 @@ export function MCPServersSection() { } }; + const handleTestServer = async (server: MCPServerConfig) => { + setServerTestStates((prev) => ({ + ...prev, + [server.id]: { status: 'testing' }, + })); + + try { + const api = getHttpApiClient(); + const result = await api.mcp.testServer(server.id); + + if (result.success) { + setServerTestStates((prev) => ({ + ...prev, + [server.id]: { + status: 'success', + tools: result.tools, + connectionTime: result.connectionTime, + }, + })); + // Auto-expand to show tools + setExpandedServers((prev) => new Set([...prev, server.id])); + toast.success( + `Connected to ${server.name} (${result.tools?.length || 0} tools, ${result.connectionTime}ms)` + ); + } else { + setServerTestStates((prev) => ({ + ...prev, + [server.id]: { + status: 'error', + error: result.error, + connectionTime: result.connectionTime, + }, + })); + toast.error(`Failed to connect: ${result.error}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setServerTestStates((prev) => ({ + ...prev, + [server.id]: { + status: 'error', + error: errorMessage, + }, + })); + toast.error(`Test failed: ${errorMessage}`); + } + }; + + const toggleServerExpanded = (serverId: string) => { + setExpandedServers((prev) => { + const next = new Set(prev); + if (next.has(serverId)) { + next.delete(serverId); + } else { + next.add(serverId); + } + return next; + }); + }; + + const getTestStatusIcon = (status: ServerTestState['status']) => { + switch (status) { + case 'testing': + return ; + case 'success': + return ; + case 'error': + return ; + default: + return null; + } + }; + const handleOpenAddDialog = () => { setFormData(defaultFormData); setEditingServer(null); @@ -112,6 +207,8 @@ export function MCPServersSection() { command: server.command || '', args: server.args?.join(' ') || '', url: server.url || '', + headers: server.headers ? JSON.stringify(server.headers, null, 2) : '', + env: server.env ? JSON.stringify(server.env, null, 2) : '', }); setEditingServer(server); setIsAddDialogOpen(true); @@ -139,6 +236,36 @@ export function MCPServersSection() { return; } + // Parse headers if provided + let parsedHeaders: Record | undefined; + if (formData.headers.trim()) { + try { + parsedHeaders = JSON.parse(formData.headers.trim()); + if (typeof parsedHeaders !== 'object' || Array.isArray(parsedHeaders)) { + toast.error('Headers must be a JSON object'); + return; + } + } catch { + toast.error('Invalid JSON for headers'); + return; + } + } + + // Parse env if provided + let parsedEnv: Record | undefined; + if (formData.env.trim()) { + try { + parsedEnv = JSON.parse(formData.env.trim()); + if (typeof parsedEnv !== 'object' || Array.isArray(parsedEnv)) { + toast.error('Environment variables must be a JSON object'); + return; + } + } catch { + toast.error('Invalid JSON for environment variables'); + return; + } + } + const serverData: Omit = { name: formData.name.trim(), description: formData.description.trim() || undefined, @@ -151,8 +278,14 @@ export function MCPServersSection() { if (formData.args.trim()) { serverData.args = formData.args.trim().split(/\s+/); } + if (parsedEnv) { + serverData.env = parsedEnv; + } } else { serverData.url = formData.url.trim(); + if (parsedHeaders) { + serverData.headers = parsedHeaders; + } } if (editingServer) { @@ -414,63 +547,139 @@ export function MCPServersSection() {
{mcpServers.map((server) => { const Icon = getServerIcon(server.type); + const testState = serverTestStates[server.id]; + const isExpanded = expandedServers.has(server.id); + const hasTools = testState?.tools && testState.tools.length > 0; + return ( -
toggleServerExpanded(server.id)} > -
-
- -
-
-
{server.name}
- {server.description && ( -
{server.description}
- )} -
- {server.type === 'stdio' - ? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}` - : server.url} +
+
+
+ + + +
+
+ + handleToggleEnabled(server)} + data-testid={`mcp-server-toggle-${server.id}`} + /> + +
+ {hasTools && ( + +
+
+ Available Tools +
+ +
+
+ )}
-
- handleToggleEnabled(server)} - data-testid={`mcp-server-toggle-${server.id}`} - /> - - -
-
+ ); })}
@@ -545,18 +754,44 @@ export function MCPServersSection() { data-testid="mcp-server-args-input" />
+
+ +