import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { addServerConfig } from '../addServerConfig.ts'; import { handleKillCommand } from '../kill-command.ts'; import { handleList } from '../list-command.ts'; import { handleListPorts } from '../list-ports-command.ts'; import { handleOpenBrowser } from '../open-browser-command.ts'; import { handleLogsCommand } from '../logs-command.ts'; import { handleRestart } from '../restart-command.ts'; import { startOneService } from '../start-command.ts'; import { infoLog } from '../logs.ts'; import { findPackageJson } from '../findPackageJson.ts'; import { ConsoleLogInterceptor } from './ConsoleLogInterceptor.ts'; import { findProjectDir } from '../configFile.ts'; async function callWrapped(handler: (args: any) => Promise, args: any) { const logWrapper = new ConsoleLogInterceptor(); logWrapper.install(); let result: any = undefined; let error: any = undefined; try { result = await handler(args); } catch (e) { error = e; } finally { logWrapper.remove(); } if (error) { error = { message: error?.message, stack: error?.stack, ...error, }; } return { result, error, logs: logWrapper.takeLogs(), }; } const DEFAULT_LOGS_LIMIT = 200; export interface ToolDefinition { name: string; description: string; inputSchema: { type: 'object'; properties: Record; required?: string[]; }; handler: (args: any) => Promise; } const toolDefinitions: ToolDefinition[] = [ { name: 'ListServices', description: 'List services with structured output', inputSchema: { type: 'object', properties: { showAll: { type: 'boolean', description: 'Show all services or just current directory (optional)', }, }, }, handler: async args => { const showAll = args?.showAll as boolean | undefined; const listOutput = await handleList({ showAll }); return listOutput; }, }, { name: 'ListPorts', description: 'List open ports for running services', inputSchema: { type: 'object', properties: { showAll: { type: 'boolean', description: 'Show ports for all services or just current directory (optional)', }, serviceName: { type: 'string', description: 'Filter to a specific service name (optional)', }, }, }, handler: async args => { const showAll = args?.showAll as boolean | undefined; const serviceName = args?.serviceName as string | undefined; const portsOutput = await handleListPorts({ showAll, commandNames: serviceName ? [serviceName] : [] }); return portsOutput; }, }, { name: 'OpenBrowser', description: 'Open a browser window to a running service\'s port', inputSchema: { type: 'object', properties: { serviceName: { type: 'string', description: 'Name of the service to open in browser', }, }, required: ['serviceName'], }, handler: async args => { const serviceName = args?.serviceName as string; if (!serviceName) { throw new McpError(ErrorCode.InvalidRequest, 'Service name is required'); } const projectDir = findProjectDir(); const result = await handleOpenBrowser({ projectDir, serviceName }); return result; }, }, { name: 'GetLogs', description: 'Get recent logs for a specific service', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name of the service to get logs for', }, limit: { type: 'number', description: 'Maximum number of log lines to return (optional)', }, projectDir: { type: 'string', description: 'Project directory where the service is defined (optional - for cross-directory access)', }, }, required: ['name'], }, handler: async args => { const result = await handleLogsCommand({ commandNames: [args?.name as string], limit: args?.limit ?? DEFAULT_LOGS_LIMIT, projectDir: args?.projectDir as string | undefined, }); return result; }, }, { name: 'StartService', description: 'Start a config-defined service (use StartTransientService for transient processes)', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name of the service to start', }, }, required: ['name'], }, handler: async args => { const name = args?.name as string; if (!name) { throw new McpError(ErrorCode.InvalidRequest, 'Service name is required'); } const projectDir = findProjectDir(); const result = await startOneService({ projectDir, commandName: name, consoleOutputFormat: 'pretty', }); return result; }, }, { name: 'StartTransientService', description: 'Start a transient process with a custom shell command (not defined in config file)', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name for the transient process', }, shell: { type: 'string', description: 'Shell command to run the service', }, root: { type: 'string', description: 'Root directory for the service (optional, relative to project)', }, }, required: ['name', 'shell'], }, handler: async args => { const { name, shell, root } = args; if (!name || !shell) { throw new McpError(ErrorCode.InvalidRequest, 'Service name and shell command are required'); } const projectDir = findProjectDir(); const result = await startOneService({ projectDir, commandName: name as string, consoleOutputFormat: 'pretty', shell: shell as string, root: root as string | undefined, }); return result; }, }, { name: 'KillService', description: 'Kill a running service', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name of the service to kill', }, }, required: ['name'], }, handler: async args => { const name = args?.name as string; if (!name) { throw new McpError(ErrorCode.InvalidRequest, 'Service name is required'); } const projectDir = findProjectDir(); await handleKillCommand({ projectDir, commandNames: [name], }); }, }, { name: 'RestartService', description: 'Restart a running service. If no name provided, restarts all running services in the project.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name of the service to restart. If not provided, restarts all running services.', }, }, }, handler: async args => { const name = args?.name as string | undefined; const projectDir = findProjectDir(); const result = await handleRestart({ projectDir, commandNames: name ? [name] : [], consoleOutputFormat: 'pretty', }); return result; }, }, { name: 'AddServerConfig', description: 'Add a new server configuration to .candle.json', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name of the service', }, shell: { type: 'string', description: 'Shell command to run the service', }, root: { type: 'string', description: 'Root directory for the service (optional)', }, }, required: ['name', 'shell'], }, handler: async args => { const { name, shell, root } = args; if (!name || !shell) { throw new McpError(ErrorCode.InvalidRequest, 'Service name and shell command are required'); } addServerConfig({ name, shell, root, }); console.log(`Service '${name}' added successfully to .candle.json`); }, }, ]; export async function serveMCP() { infoLog('MCP: Starting MCP server'); const packageInfo = findPackageJson(); // Create server with proper initialization const server = new Server( { name: packageInfo.name, version: packageInfo.version, }, { capabilities: { tools: {}, }, instructions: 'Tool for running and managing local dev servers. Use this when launching any local servers, including ' + 'web servers, APIs, and other services.', } ); // Register tool list handler server.setRequestHandler(ListToolsRequestSchema, async (request: any) => { infoLog('MCP: Received ListTools request:', request); const response = { tools: toolDefinitions.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, })), }; infoLog('MCP: Responding to ListTools:', response); return response; }); // Register tool call handler server.setRequestHandler(CallToolRequestSchema, async (request: any) => { const { name, arguments: args } = request.params; infoLog('MCP: Received CallTool request:', request); const tool = toolDefinitions.find(t => t.name === name); if (!tool) { infoLog(`MCP: CallTool error - Unknown tool: ${name}`); throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } const callResult = await callWrapped(tool.handler, args); // Convert the wrapped result to MCP tool response format const content: any[] = []; // Add logs as text content if present if (callResult.logs && callResult.logs.length > 0) { content.push({ type: 'text', text: callResult.logs.join('\n') }); } // Add result or error if (callResult.error) { content.push({ type: 'text', text: `Error: ${callResult.error.message}` }); return { content, isError: true }; } else { // Add result as structured content if (callResult.result !== undefined) { content.push({ type: 'text', text: JSON.stringify(callResult.result, null, 2) }); } return { content, isError: false }; } }); // Create transport and connect const transport = new StdioServerTransport(); // Shut down the process when stdin is closed. // The transport doesn't seem to automatically listen to 'stdin' close events.. process.stdin.on('close', async () => { infoLog('MCP: stdin closed'); await transport.close(); process.exit(0); }); await server.connect(transport); infoLog('MCP: Server launched and connected'); } export async function main(): Promise { await serveMCP(); }