Files
Codeman/src/mcp-server.ts
T
arkonandClaude Opus 4.5 99919d6dfb 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>
2026-01-23 23:16:21 +01:00

371 lines
12 KiB
JavaScript

#!/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);
});