mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
feat: replace SpawnDetector with MCP server for spawn1337 protocol
Instead of parsing terminal output for <spawn1337> tags, spawn capabilities are now exposed as native MCP tools that Claude Code can call directly. The MCP server (stdio transport) proxies requests to the existing REST API. - Add src/mcp-server.ts with 6 tools: spawn_agent, list_agents, get_agent_status, get_agent_result, send_agent_message, cancel_agent - Remove src/spawn-detector.ts and all references in session.ts/server.ts - Add CLAUDEMAN_API_URL env var propagation to sessions and screens - Write .mcp.json to case directories during creation - Remove spawn1337 tag documentation from case-template.md - Add claudeman-mcp bin entry to package.json Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @fileoverview MCP Server for Claudeman Spawn1337 protocol.
|
||||
*
|
||||
* Exposes spawn capabilities as native MCP tools that Claude Code can call
|
||||
* directly, replacing the terminal-tag-parsing approach (SpawnDetector).
|
||||
*
|
||||
* Tools:
|
||||
* - spawn_agent: Spawn a new autonomous agent
|
||||
* - list_agents: List all agents (active + completed)
|
||||
* - get_agent_status: Get detailed agent status
|
||||
* - get_agent_result: Read a completed agent's result
|
||||
* - send_agent_message: Send a message to a running agent
|
||||
* - cancel_agent: Cancel a running agent
|
||||
*
|
||||
* Environment:
|
||||
* - CLAUDEMAN_API_URL: Base URL for the Claudeman API (default: http://localhost:3000)
|
||||
* - CLAUDEMAN_SESSION_ID: Session ID of the calling Claude session
|
||||
*
|
||||
* @module mcp-server
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
// ========== Configuration ==========
|
||||
|
||||
const API_URL = process.env.CLAUDEMAN_API_URL || 'http://localhost:3000';
|
||||
const SESSION_ID = process.env.CLAUDEMAN_SESSION_ID || '';
|
||||
|
||||
// ========== API Helper ==========
|
||||
|
||||
/**
|
||||
* Make an HTTP request to the Claudeman API.
|
||||
*/
|
||||
async function apiRequest(method: string, path: string, body?: unknown): Promise<{ status: number; data: unknown }> {
|
||||
const url = `${API_URL}${path}`;
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
return { status: response.status, data };
|
||||
}
|
||||
|
||||
// ========== YAML Construction ==========
|
||||
|
||||
/**
|
||||
* Build a YAML frontmatter + body task spec from structured parameters.
|
||||
*/
|
||||
export function buildTaskSpec(params: {
|
||||
agentId: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
type?: string;
|
||||
priority?: string;
|
||||
maxTokens?: number;
|
||||
maxCost?: number;
|
||||
timeoutMinutes?: number;
|
||||
canModifyParentFiles?: boolean;
|
||||
contextFiles?: string[];
|
||||
dependsOn?: string[];
|
||||
completionPhrase?: string;
|
||||
outputFormat?: string;
|
||||
successCriteria?: string;
|
||||
workingDir?: string;
|
||||
}): string {
|
||||
const lines: string[] = ['---'];
|
||||
|
||||
lines.push(`agentId: ${params.agentId}`);
|
||||
lines.push(`name: ${params.name}`);
|
||||
|
||||
if (params.type) lines.push(`type: ${params.type}`);
|
||||
if (params.priority) lines.push(`priority: ${params.priority}`);
|
||||
if (params.maxTokens != null) lines.push(`maxTokens: ${params.maxTokens}`);
|
||||
if (params.maxCost != null) lines.push(`maxCost: ${params.maxCost}`);
|
||||
if (params.timeoutMinutes != null) lines.push(`timeoutMinutes: ${params.timeoutMinutes}`);
|
||||
if (params.canModifyParentFiles != null) lines.push(`canModifyParentFiles: ${params.canModifyParentFiles}`);
|
||||
if (params.completionPhrase) lines.push(`completionPhrase: ${params.completionPhrase}`);
|
||||
if (params.outputFormat) lines.push(`outputFormat: ${params.outputFormat}`);
|
||||
if (params.successCriteria) lines.push(`successCriteria: "${params.successCriteria.replace(/"/g, '\\"')}"`);
|
||||
if (params.workingDir) lines.push(`workingDir: ${params.workingDir}`);
|
||||
|
||||
if (params.contextFiles && params.contextFiles.length > 0) {
|
||||
lines.push(`contextFiles: [${params.contextFiles.join(', ')}]`);
|
||||
}
|
||||
|
||||
if (params.dependsOn && params.dependsOn.length > 0) {
|
||||
lines.push(`dependsOn: [${params.dependsOn.join(', ')}]`);
|
||||
}
|
||||
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push(params.instructions);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ========== MCP Server Setup ==========
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'claudeman-spawn',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// ---- spawn_agent ----
|
||||
|
||||
server.tool(
|
||||
'spawn_agent',
|
||||
'Spawn a new autonomous Claude agent to handle a subtask. The agent runs in its own session with full capabilities.',
|
||||
{
|
||||
agentId: z.string().describe('Unique identifier for the agent (e.g., "research-auth-001")'),
|
||||
name: z.string().describe('Human-readable name for the agent'),
|
||||
instructions: z.string().describe('Detailed task instructions for the agent (markdown)'),
|
||||
type: z.enum(['explore', 'implement', 'test', 'review', 'refactor', 'research', 'generate', 'fix', 'general']).optional().describe('Task type/category'),
|
||||
priority: z.enum(['low', 'normal', 'high', 'critical']).optional().describe('Priority for queue ordering'),
|
||||
maxTokens: z.number().optional().describe('Maximum token budget (input + output combined)'),
|
||||
maxCost: z.number().optional().describe('Maximum cost in USD'),
|
||||
timeoutMinutes: z.number().optional().describe('Maximum runtime in minutes (max: 120)'),
|
||||
canModifyParentFiles: z.boolean().optional().describe('Whether agent can modify files in parent project directory'),
|
||||
contextFiles: z.array(z.string()).optional().describe('Files to symlink into agent workspace as context'),
|
||||
dependsOn: z.array(z.string()).optional().describe('Agent IDs that must complete before this one starts'),
|
||||
completionPhrase: z.string().optional().describe('Phrase agent outputs when finished (default: auto-generated)'),
|
||||
outputFormat: z.enum(['markdown', 'json', 'code', 'structured', 'freeform']).optional().describe('Expected output format'),
|
||||
successCriteria: z.string().optional().describe('Success criteria included in agent instructions'),
|
||||
workingDir: z.string().optional().describe('Working directory (relative to parent, or absolute)'),
|
||||
},
|
||||
async (params) => {
|
||||
if (!SESSION_ID) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: CLAUDEMAN_SESSION_ID not set. This tool must be run within a Claudeman-managed session.' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const taskSpec = buildTaskSpec(params);
|
||||
|
||||
try {
|
||||
const { status, data } = await apiRequest('POST', '/api/spawn/trigger', {
|
||||
parentSessionId: SESSION_ID,
|
||||
taskContent: taskSpec,
|
||||
});
|
||||
|
||||
if (status >= 400) {
|
||||
const errorData = data as { error?: { code?: string; details?: string } };
|
||||
const errorMsg = errorData.error?.details || errorData.error?.code || 'Unknown error';
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error spawning agent: ${errorMsg}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = data as { success: boolean; data?: { agentId: string } };
|
||||
const agentId = result.data?.agentId || params.agentId;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent spawned successfully.\n\nAgent ID: ${agentId}` }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}. Is the web server running?\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- list_agents ----
|
||||
|
||||
server.tool(
|
||||
'list_agents',
|
||||
'List all spawn agents (active, queued, and completed).',
|
||||
{},
|
||||
async () => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('GET', '/api/spawn/agents');
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error listing agents: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const agents = data as Array<{ agentId: string; name: string; status: string; type: string; priority: string }>;
|
||||
if (agents.length === 0) {
|
||||
return { content: [{ type: 'text', text: 'No agents found.' }] };
|
||||
}
|
||||
|
||||
const summary = agents.map(a => `- ${a.agentId} (${a.name}): ${a.status} [${a.type}, ${a.priority}]`).join('\n');
|
||||
return { content: [{ type: 'text', text: `Agents (${agents.length}):\n\n${summary}` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- get_agent_status ----
|
||||
|
||||
server.tool(
|
||||
'get_agent_status',
|
||||
'Get detailed status and progress of a specific agent.',
|
||||
{
|
||||
agentId: z.string().describe('The agent ID to query'),
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('GET', `/api/spawn/agents/${encodeURIComponent(agentId)}`);
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent not found: ${agentId}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error getting agent status: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- get_agent_result ----
|
||||
|
||||
server.tool(
|
||||
'get_agent_result',
|
||||
'Read the result of a completed agent. Returns the agent\'s output and metadata.',
|
||||
{
|
||||
agentId: z.string().describe('The agent ID whose result to read'),
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('GET', `/api/spawn/agents/${encodeURIComponent(agentId)}/result`);
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent or result not found: ${agentId}. The agent may not have completed yet.` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error getting agent result: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Result could be a string (raw markdown) or an object
|
||||
const resultText = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
return { content: [{ type: 'text', text: resultText }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- send_agent_message ----
|
||||
|
||||
server.tool(
|
||||
'send_agent_message',
|
||||
'Send a message to a running agent. The message is written to the agent\'s communication channel.',
|
||||
{
|
||||
agentId: z.string().describe('The agent ID to message'),
|
||||
message: z.string().describe('The message content (markdown)'),
|
||||
},
|
||||
async ({ agentId, message }) => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('POST', `/api/spawn/agents/${encodeURIComponent(agentId)}/message`, {
|
||||
content: message,
|
||||
sender: 'parent',
|
||||
});
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent not found: ${agentId}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error sending message: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `Message sent to agent ${agentId}.` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---- cancel_agent ----
|
||||
|
||||
server.tool(
|
||||
'cancel_agent',
|
||||
'Cancel a running agent. Sends a graceful shutdown signal.',
|
||||
{
|
||||
agentId: z.string().describe('The agent ID to cancel'),
|
||||
reason: z.string().optional().describe('Reason for cancellation'),
|
||||
},
|
||||
async ({ agentId, reason }) => {
|
||||
try {
|
||||
const { status, data } = await apiRequest('POST', `/api/spawn/agents/${encodeURIComponent(agentId)}/cancel`, {
|
||||
reason: reason || 'Cancelled by parent session',
|
||||
});
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Agent not found: ${agentId}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error cancelling agent: ${JSON.stringify(data)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `Agent ${agentId} cancel request sent.` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: Could not connect to Claudeman API at ${API_URL}.\n\n${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ========== Start Server ==========
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('MCP server failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user