mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
feat: add MCP server testing and tool listing functionality
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
20
apps/server/src/routes/mcp/common.ts
Normal file
20
apps/server/src/routes/mcp/common.ts
Normal file
@@ -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);
|
||||
}
|
||||
36
apps/server/src/routes/mcp/index.ts
Normal file
36
apps/server/src/routes/mcp/index.ts
Normal file
@@ -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;
|
||||
}
|
||||
67
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
67
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
@@ -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<void> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
62
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
62
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
@@ -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<void> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
208
apps/server/src/services/mcp-test-service.ts
Normal file
208
apps/server/src/services/mcp-test-service.ts
Normal file
@@ -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<MCPTestResult> {
|
||||
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<string, unknown>;
|
||||
}>;
|
||||
}>(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<string, unknown> }) => ({
|
||||
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<MCPTestResult> {
|
||||
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<StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport> {
|
||||
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<T>(ms: number, message: string): Promise<T> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, ServerTestState>>({});
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(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 <Loader2 className="w-4 h-4 animate-spin text-brand-500" />;
|
||||
case 'success':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||
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<string, string> | 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<string, string> | 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<MCPServerConfig, 'id'> = {
|
||||
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() {
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div
|
||||
<Collapsible
|
||||
key={server.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-xl border',
|
||||
server.enabled !== false
|
||||
? 'border-border/50 bg-accent/20'
|
||||
: 'border-border/30 bg-muted/30 opacity-60'
|
||||
)}
|
||||
data-testid={`mcp-server-${server.id}`}
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleServerExpanded(server.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center',
|
||||
server.enabled !== false ? 'bg-brand-500/20' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{server.name}</div>
|
||||
{server.description && (
|
||||
<div className="text-xs text-muted-foreground">{server.description}</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground/60 mt-0.5">
|
||||
{server.type === 'stdio'
|
||||
? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}`
|
||||
: server.url}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border',
|
||||
server.enabled !== false
|
||||
? 'border-border/50 bg-accent/20'
|
||||
: 'border-border/30 bg-muted/30 opacity-60'
|
||||
)}
|
||||
data-testid={`mcp-server-${server.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 overflow-hidden">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-3 text-left min-w-0 flex-1',
|
||||
hasTools && 'cursor-pointer hover:opacity-80'
|
||||
)}
|
||||
disabled={!hasTools}
|
||||
>
|
||||
{hasTools ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
||||
server.enabled !== false ? 'bg-brand-500/20' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm truncate">{server.name}</span>
|
||||
{testState && getTestStatusIcon(testState.status)}
|
||||
{testState?.status === 'success' && testState.tools && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
{testState.tools.length} tool
|
||||
{testState.tools.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{server.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{server.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground/60 mt-0.5 truncate">
|
||||
{server.type === 'stdio'
|
||||
? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}`
|
||||
: server.url}
|
||||
</div>
|
||||
{testState?.status === 'error' && testState.error && (
|
||||
<div className="text-xs text-destructive mt-1 line-clamp-2 break-words">
|
||||
{testState.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestServer(server)}
|
||||
disabled={testState?.status === 'testing' || server.enabled === false}
|
||||
data-testid={`mcp-server-test-${server.id}`}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
{testState?.status === 'testing' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
)}
|
||||
<span className="ml-1.5 text-xs">Test</span>
|
||||
</Button>
|
||||
<Switch
|
||||
checked={server.enabled !== false}
|
||||
onCheckedChange={() => handleToggleEnabled(server)}
|
||||
data-testid={`mcp-server-toggle-${server.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenEditDialog(server)}
|
||||
data-testid={`mcp-server-edit-${server.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteConfirmId(server.id)}
|
||||
data-testid={`mcp-server-delete-${server.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{hasTools && (
|
||||
<CollapsibleContent>
|
||||
<div className="px-4 pb-4 pt-0 ml-7 overflow-hidden">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Available Tools
|
||||
</div>
|
||||
<MCPToolsList
|
||||
tools={testState.tools!}
|
||||
isLoading={testState.status === 'testing'}
|
||||
error={testState.error}
|
||||
className="max-w-full"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={server.enabled !== false}
|
||||
onCheckedChange={() => handleToggleEnabled(server)}
|
||||
data-testid={`mcp-server-toggle-${server.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenEditDialog(server)}
|
||||
data-testid={`mcp-server-edit-${server.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteConfirmId(server.id)}
|
||||
data-testid={`mcp-server-delete-${server.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -545,18 +754,44 @@ export function MCPServersSection() {
|
||||
data-testid="mcp-server-args-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-env">Environment Variables (JSON, optional)</Label>
|
||||
<Textarea
|
||||
id="server-env"
|
||||
value={formData.env}
|
||||
onChange={(e) => setFormData({ ...formData, env: e.target.value })}
|
||||
placeholder={'{\n "API_KEY": "your-key"\n}'}
|
||||
className="font-mono text-sm h-24"
|
||||
data-testid="mcp-server-env-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">URL</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://example.com/mcp"
|
||||
data-testid="mcp-server-url-input"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">URL</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://example.com/mcp"
|
||||
data-testid="mcp-server-url-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-headers">Headers (JSON, optional)</Label>
|
||||
<Textarea
|
||||
id="server-headers"
|
||||
value={formData.headers}
|
||||
onChange={(e) => setFormData({ ...formData, headers: e.target.value })}
|
||||
placeholder={
|
||||
'{\n "x-api-key": "your-api-key",\n "Authorization": "Bearer token"\n}'
|
||||
}
|
||||
className="font-mono text-sm h-24"
|
||||
data-testid="mcp-server-headers-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
export interface MCPToolDisplay {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface MCPToolsListProps {
|
||||
tools: MCPToolDisplay[];
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MCPToolsList({ tools, isLoading, error, className }: MCPToolsListProps) {
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleTool = (toolName: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(toolName)) {
|
||||
next.delete(toolName);
|
||||
} else {
|
||||
next.add(toolName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground animate-pulse', className)}>
|
||||
Loading tools...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className={cn('text-sm text-destructive break-words', className)}>{error}</div>;
|
||||
}
|
||||
|
||||
if (!tools || tools.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground italic', className)}>
|
||||
No tools available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1 overflow-hidden', className)}>
|
||||
{tools.map((tool) => {
|
||||
const isExpanded = expandedTools.has(tool.name);
|
||||
const hasSchema = tool.inputSchema && Object.keys(tool.inputSchema).length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible key={tool.name} open={isExpanded} onOpenChange={() => toggleTool(tool.name)}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border/30 bg-background/50 overflow-hidden',
|
||||
'hover:border-border/50 transition-colors'
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start h-auto py-2 px-3 font-normal"
|
||||
>
|
||||
<div className="flex items-start gap-2 w-full min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
|
||||
{hasSchema ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-3.5" />
|
||||
)}
|
||||
<Wrench className="w-3.5 h-3.5 text-brand-500" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start text-left min-w-0 overflow-hidden flex-1">
|
||||
<span className="font-medium text-xs truncate max-w-full">{tool.name}</span>
|
||||
{tool.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2 break-words w-full">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{hasSchema && (
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pb-2 pt-0 overflow-hidden">
|
||||
<div className="bg-muted/50 rounded p-2 text-xs font-mono overflow-x-auto max-h-48">
|
||||
<pre className="whitespace-pre-wrap break-all text-[10px] leading-relaxed">
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1139,6 +1139,67 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
// MCP API - Test MCP server connections and list tools
|
||||
mcp = {
|
||||
testServer: (
|
||||
serverId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
error?: string;
|
||||
connectionTime?: number;
|
||||
serverInfo?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
}> => this.post('/api/mcp/test', { serverId }),
|
||||
|
||||
testServerConfig: (serverConfig: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type?: 'stdio' | 'sse' | 'http';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
error?: string;
|
||||
connectionTime?: number;
|
||||
serverInfo?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
}> => this.post('/api/mcp/test', { serverConfig }),
|
||||
|
||||
listTools: (
|
||||
serverId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
error?: string;
|
||||
}> => this.post('/api/mcp/tools', { serverId }),
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -173,6 +173,8 @@ export interface MCPToolInfo {
|
||||
name: string;
|
||||
/** Description of what the tool does */
|
||||
description?: string;
|
||||
/** JSON Schema for the tool's input parameters */
|
||||
inputSchema?: Record<string, unknown>;
|
||||
/** Whether this tool is enabled for use (defaults to true) */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user