UNPKG

mcp-server-subagent

Version:
443 lines (442 loc) 18.7 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { promises as fs } from "fs"; import { join } from "path"; import { homedir, tmpdir } from "os"; import { RunSubagentArgumentsSchema, CheckSubagentStatusArgumentsSchema, GetSubagentLogsArgumentsSchema, UpdateSubagentStatusArgumentsSchema, } from "./tools/schemas.js"; import { runSubagent } from "./tools/run.js"; import { checkSubagentStatus, updateSubagentStatus } from "./tools/status.js"; import { getSubagentLogs } from "./tools/logs.js"; import { AskParentInputSchema, AskParentOutputSchema, askParentHandler, } from "./tools/askParent.js"; import { replySubagentInputSchema, replySubagentOutputSchema, replySubagentHandler, } from "./tools/replySubagent.js"; import { CheckMessageStatusArgumentsSchema, CheckMessageStatusOutputSchema, checkMessageStatusHandler, } from "./tools/checkMessage.js"; // Function to determine the log directory with fallbacks function getLogDir() { const candidates = [ join(homedir(), ".config", "mcp-server-subagent", "logs"), join(process.cwd(), "logs"), join(tmpdir(), "mcp-server-subagent", "logs"), ]; return candidates[0]; // Start with the first candidate } // Define the log directory export let LOG_DIR = getLogDir(); // MCP configuration for Claude CLI export const mcpConfig = { mcpServers: { subagent: { command: "npx", args: ["-y", "mcp-server-subagent"], }, }, }; // Define the subagent configuration export const SUBAGENTS = { q: { name: "q", command: "q", getArgs: () => ["chat", "--trust-all-tools", "--no-interactive"], description: "Run a query through the Amazon Q CLI", }, claude: { name: "claude", command: "claude", getArgs: () => [ "--print", "--verbose", "--output-format", "stream-json", "--allowedTools", "Bash(git*),Bash(make*),Bash(just*),Bash(gh*),Bash(npm*),Bash(node*),Bash(go*),Bash,Edit,Write,mcp__subagent__update_subagent_status,mcp__subagent__ask_parent,mcp__subagent__check_message_status", "--mcp-config", JSON.stringify(mcpConfig), ], description: "Run a query through the Claude CLI", }, // test and test_fail agents will be removed from here and added in tests }; // Create server instance const server = new Server({ name: "subagent", version: "1.0.0", }, { capabilities: { tools: {}, }, }); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = []; // Add run tools for each subagent for (const subagent of Object.values(SUBAGENTS)) { // Exclude the 'test' subagent from being exposed if (subagent.name === "test") { continue; } tools.push({ name: `run_subagent_${subagent.name}`, description: `Delegates the given task to a ${subagent.name} subagent as an asynchronous sub-task. This creates a new agent instance that will handle the provided input independently and report back its results. The task can be short or long-running. Use check_subagent_status with the returned runId to monitor progress, as completion may take some time.\nSynonyms: Run subtask, run sub-agent, delegate task, delegate sub-task.\n${subagent.description}`, inputSchema: { type: "object", properties: { input: { type: "string", description: "Input to send to the subagent", }, cwd: { type: "string", description: "Working directory path (project root) where the subagent should be executed. Set this to the current working directory, or the current project root, usually the directory with the .git/ folder.", }, }, required: ["input", "cwd"], }, }); } // Add generic status check tool tools.push({ name: "check_subagent_status", description: "Check the status of a subagent run. Since sub-agents can take a while it is recommneded to wait at least 30 or 60 seconds (`sleep 30`) in between polling intervals.", inputSchema: { type: "object", properties: { runId: { type: "string", description: "Run ID to check status for", }, }, required: ["runId"], }, }); // Add generic logs retrieval tool tools.push({ name: "get_subagent_logs", description: "Get the logs of a subagent run. This can be VERY long, so only use this tool if instructed. Requires only the runId.", inputSchema: { type: "object", properties: { runId: { type: "string", description: "Run ID to get logs for", }, }, required: ["runId"], }, }); // Add generic update status tool tools.push({ name: "update_subagent_status", description: "Update the status and summary of a subagent run. This tool is meant to be used from sub-agents, not the main agent. Requires runId and status.", inputSchema: { type: "object", properties: { runId: { type: "string", description: "Run ID to update status for", }, status: { type: "string", enum: [ "success", "error", "running", "completed", "waiting_parent_reply", "parent_replied", ], description: "New status to set", }, summary: { type: "string", description: "Summary or result message to include with the status update", }, }, required: ["runId", "status"], }, }); // Add bi-directional communication tools tools.push({ name: "ask_parent", description: "Enables the subagent to ask a question to the parent agent.", inputSchema: { type: "object", properties: { runId: { type: "string", description: "The subagent's current run ID", }, question: { type: "string", description: "The question/message content", }, }, required: ["runId", "question"], }, }); tools.push({ name: "reply_subagent", description: "Enables the parent to reply to a specific question from a subagent.", inputSchema: { type: "object", properties: { runId: { type: "string", description: "The subagent's run ID", }, messageId: { type: "string", description: "The ID of the message being replied to", }, answer: { type: "string", description: "The parent's reply content", }, }, required: ["runId", "messageId", "answer"], }, }); tools.push({ name: "check_message_status", description: "Check the status of a specific message and retrieve the reply if available. This will acknowledge the message if it has been replied to.", inputSchema: { type: "object", properties: { runId: { type: "string", description: "Run ID to check message status for", }, messageId: { type: "string", description: "Message ID to check status for", }, }, required: ["runId", "messageId"], }, }); return { tools }; }); // Ensure log directory exists with fallback paths export async function ensureLogDir() { const candidates = [ // First try: user config directory (consistent across processes) join(homedir(), ".config", "mcp-server-subagent", "logs"), // Second try: current working directory join(process.cwd(), "logs"), // Last resort: temp directory join(tmpdir(), "mcp-server-subagent", "logs"), ]; for (const candidate of candidates) { try { await fs.mkdir(candidate, { recursive: true }); LOG_DIR = candidate; console.error(`Using log directory: ${LOG_DIR}`); return; } catch (error) { console.error(`Failed to create log directory ${candidate}:`, error); } } throw new Error("Unable to create log directory in any of the candidate locations"); } // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { // Handle run_subagent_* tools if (name.startsWith("run_subagent_")) { const subagentName = name.replace("run_subagent_", ""); const subagentConfig = SUBAGENTS[subagentName]; if (!subagentConfig) { throw new Error(`Unknown subagent: ${subagentName}`); } const { input, cwd } = RunSubagentArgumentsSchema.parse(args); await ensureLogDir(); const runId = await runSubagent(subagentConfig, input, cwd, LOG_DIR); return { content: [ { type: "text", text: `Subagent ${subagentConfig.name} started in directory ${cwd} with run ID: ${runId}.\n\nUse check_subagent_status to check the status. As this task can take a while, periodically check status in 30 second intervals or similar (use "sleep 30").`, }, ], }; } // Handle check_subagent_status tool if (name === "check_subagent_status") { const { runId } = CheckSubagentStatusArgumentsSchema.parse(args); const statusObject = await checkSubagentStatus(runId, LOG_DIR); const outputParts = []; outputParts.push(`Run ID: ${statusObject.runId || runId}`); outputParts.push(`Agent Name: ${statusObject.agentName || "N/A"}`); outputParts.push(`Status: ${statusObject.status || "N/A"}`); outputParts.push(`Exit Code: ${statusObject.exitCode ?? "N/A"}`); outputParts.push(`Start Time: ${statusObject.startTime || "N/A"}`); outputParts.push(`End Time: ${statusObject.endTime || "N/A"}`); outputParts.push(`Summary: ${statusObject.summary || "N/A"}`); // Bi-directional communication details if (Array.isArray(statusObject.messages) && statusObject.messages.length > 0) { if (statusObject.status === "waiting_parent_reply") { const pendingMessage = statusObject.messages .slice() .reverse() .find((m) => m.messageStatus === "pending_parent_reply"); if (pendingMessage) { outputParts.push(``); outputParts.push(`Question awaiting reply (Message ID: ${pendingMessage.messageId}):`); outputParts.push(` ${pendingMessage.questionContent}`); outputParts.push(` (Asked at: ${pendingMessage.questionTimestamp})`); outputParts.push(` To reply, use the 'reply_subagent' tool.`); outputParts.push(``); outputParts.push(`Note: This may take a while for the parent to respond. Use 'sleep 30' between status checks to avoid spamming.`); } } else if (statusObject.status === "parent_replied" || statusObject.status === "running") { // Find the most recent message with an answer and acknowledged_by_subagent status const repliedMessage = statusObject.messages .slice() .reverse() .find((m) => m.answerContent && (m.messageStatus === "acknowledged_by_subagent" || m.messageStatus === "parent_replied")); if (repliedMessage) { outputParts.push(``); outputParts.push(`Last Interaction (Message ID: ${repliedMessage.messageId}):`); outputParts.push(` Question: ${repliedMessage.questionContent}`); outputParts.push(` (Asked at: ${repliedMessage.questionTimestamp})`); outputParts.push(` Answer: ${repliedMessage.answerContent}`); outputParts.push(` (Answered at: ${repliedMessage.answerTimestamp})`); } } } // Add general status-specific notes if (statusObject.status === "running") { outputParts.push(``); outputParts.push(`Note: Task is still running. This may take a while. Use 'sleep 30' between status checks to avoid spamming.`); } else if (statusObject.status === "waiting_parent_reply" && (!Array.isArray(statusObject.messages) || statusObject.messages.length === 0)) { outputParts.push(``); outputParts.push(`Note: Waiting for parent reply. This may take a while. Use 'sleep 30' between status checks to avoid spamming.`); } const textOutput = outputParts.join("\n"); return { content: [ { type: "text", text: textOutput, }, ], }; } // Handle get_subagent_logs tool if (name === "get_subagent_logs") { const { runId } = GetSubagentLogsArgumentsSchema.parse(args); const logs = await getSubagentLogs(runId, LOG_DIR); return { content: [ { type: "text", text: `Logs for run ${runId}:\n\n${logs}`, }, ], }; } // Handle update_subagent_status tool if (name === "update_subagent_status") { const { runId, status, summary } = UpdateSubagentStatusArgumentsSchema.parse(args); const updatedStatus = await updateSubagentStatus(runId, status, LOG_DIR, summary); return { content: [ { type: "text", text: `Status for run ${runId} updated:\n\n${JSON.stringify(updatedStatus, null, 2)}`, }, ], }; } // Handle ask_parent tool if (name === "ask_parent") { const parsed = AskParentInputSchema.parse(args); const result = await askParentHandler(parsed, LOG_DIR); AskParentOutputSchema.parse(result); // Validate output return { content: [ { type: "text", text: `Message ID: ${result.messageId}\n\n${result.instructions}`, }, ], }; } // Handle reply_subagent tool if (name === "reply_subagent") { const parsed = replySubagentInputSchema.parse(args); const result = await replySubagentHandler(parsed, LOG_DIR); replySubagentOutputSchema.parse(result); // Validate output return { content: [ { type: "text", text: result.message, }, ], }; } // Handle check_message_status tool if (name === "check_message_status") { const parsed = CheckMessageStatusArgumentsSchema.parse(args); const result = await checkMessageStatusHandler(parsed, LOG_DIR); CheckMessageStatusOutputSchema.parse(result); // Validate output const outputParts = []; outputParts.push(`Message ID: ${result.messageId}`); outputParts.push(`Status: ${result.messageStatus}`); outputParts.push(`Question: ${result.questionContent}`); outputParts.push(`Asked at: ${result.questionTimestamp}`); if (result.answerContent) { outputParts.push(`Answer: ${result.answerContent}`); outputParts.push(`Answered at: ${result.answerTimestamp}`); } else { outputParts.push(`Answer: Not yet answered`); } return { content: [ { type: "text", text: outputParts.join("\n"), }, ], }; } throw new Error(`Unknown tool: ${name}`); } catch (error) { if (error instanceof z.ZodError) { throw new Error(`Invalid arguments: ${error.errors .map((e) => `${e.path.join(".")}: ${e.message}`) .join(", ")}`); } throw error; } }); // Start the server async function main() { try { await ensureLogDir(); const transport = new StdioServerTransport(); await server.connect(transport); } catch (error) { console.error("Error starting server:", error); process.exit(1); } } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });