automagik-genie
Version:
Self-evolving AI agent orchestration framework with Model Context Protocol support
1,014 lines (1,013 loc) • 49.8 kB
JavaScript
;
/**
* Genie MCP Server - Official SDK Implementation
*
* Provides Model Context Protocol access to Genie agent orchestration.
* Tools integrate with CLI via subprocess execution (shell-out pattern).
*
* TECHNICAL DEBT: Handler integration blocked by type signature mismatch:
* - CLI handlers return Promise<void> (side-effect based via emitView)
* - MCP tools need Promise<data> (pure functions returning structured data)
*
* Future improvement (v0.2.0): Refactor CLI handlers to return data directly,
* enabling zero-duplication integration. Current implementation ensures 100%
* behavioral equivalence with CLI while maintaining functional MCP server.
*
* Build status: ✅ CLI compiles (0 errors), ✅ MCP compiles (0 errors)
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
const zod_1 = require("zod");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const ws_1 = __importDefault(require("ws"));
const child_process_1 = require("child_process");
const display_transform_js_1 = require("./lib/display-transform.js");
// Import WebSocket-native tools (MVP Phase 6)
const prompt_tool_js_1 = require("./tools/prompt-tool.js");
// Import neuron architecture tools (Phase 2)
const continue_task_tool_js_1 = require("./tools/continue-task-tool.js");
const create_subtask_tool_js_1 = require("./tools/create-subtask-tool.js");
// Import role detection
const role_detector_js_1 = require("./lib/role-detector.js");
// Import HTTP server for OAuth2 transport
const http_server_js_1 = require("./lib/http-server.js");
// Import process cleanup utilities
const process_cleanup_js_1 = require("./lib/process-cleanup.js");
// Import CLI executor utilities
const cli_executor_js_1 = require("./lib/cli-executor.js");
// Import server helper utilities (extracted per Amendment #10)
const server_helpers_js_1 = require("./lib/server-helpers.js");
// Import spell utilities (extracted per Amendment #10)
const spell_utils_js_1 = require("./lib/spell-utils.js");
// Import neuron resource provider
const neuron_provider_js_1 = require("./resources/neuron-provider.js");
const service_config_js_1 = require("./lib/service-config.js");
const agent_resolver_js_1 = require("../cli/lib/agent-resolver.js");
const executor_registry_js_1 = require("../cli/lib/executor-registry.js");
const task_monitor_js_1 = require("../cli/lib/task-monitor.js");
const task_service_js_1 = require("../cli/cli-core/task-service.js");
const { port: PORT } = (0, service_config_js_1.getMcpConfig)();
const TRANSPORT = process.env.MCP_TRANSPORT || 'stdio';
const WORKSPACE_ROOT = (0, server_helpers_js_1.findWorkspaceRoot)();
// transformDisplayPath imported from ./lib/display-transform (single source of truth)
// listAgents() imported from ./lib/server-helpers.js
// loadForgeExecutor() imported from ./lib/server-helpers.js
// listTasks() imported from ./lib/server-helpers.js
// Helper: View session transcript (uses Forge API + WebSocket normalized logs)
async function viewSession(taskId) {
try {
const mod = (0, server_helpers_js_1.loadForgeExecutor)();
if (!mod || typeof mod.createForgeExecutor !== 'function') {
return {
status: 'error',
transcript: null,
error: 'Forge executor unavailable (did you build the CLI?)'
};
}
mod.createForgeExecutor(); // Warmup (ensures CLI build available)
const { ForgeClient } = require('../../src/lib/forge-client.js');
const { baseUrl, token } = (0, service_config_js_1.getForgeConfig)();
const forge = new ForgeClient(baseUrl, token);
// Attempt IDs are preferred; fall back to task IDs for backward compatibility
let attemptId = taskId;
let attemptDetails;
try {
attemptDetails = await forge.getTaskAttempt(attemptId);
}
catch (err) {
const attempts = await forge.listTaskAttempts(taskId);
if (!Array.isArray(attempts) || !attempts.length) {
return {
status: 'error',
transcript: null,
error: `No attempts found for task ${taskId}`
};
}
const latestAttempt = attempts[attempts.length - 1];
attemptId = latestAttempt.id;
attemptDetails = await forge.getTaskAttempt(attemptId);
}
const status = attemptDetails.status || 'unknown';
const latestProcess = await waitForLatestProcess(forge, attemptId);
if (!latestProcess) {
return {
status,
transcript: null,
error: 'Task executor has not produced logs yet. Try again shortly.'
};
}
const completedStatuses = new Set(['completed', 'success', 'failed', 'error', 'stopped']);
if (completedStatuses.has(status)) {
const gitTranscript = readTranscriptFromCommit(attemptDetails, latestProcess);
if (gitTranscript) {
return { status, transcript: gitTranscript };
}
}
let transcript = await streamNormalizedLogs(forge, latestProcess.id, token, 10000);
if (!transcript) {
transcript = latestProcess.output || latestProcess.logs || null;
}
if (!transcript && completedStatuses.has(status)) {
transcript = readTranscriptFromCommit(attemptDetails, latestProcess);
}
return { status, transcript };
}
catch (error) {
return {
status: 'error',
transcript: null,
error: error.message || 'Unknown error viewing task'
};
}
}
function readTranscriptFromCommit(attemptDetails, latestProcess) {
const commitSha = latestProcess?.after_head_commit || latestProcess?.afterHeadCommit;
const containerRef = attemptDetails?.container_ref || attemptDetails?.containerRef;
if (!commitSha || !containerRef) {
return null;
}
const gitDir = path_1.default.join(containerRef, '.git');
if (!fs_1.default.existsSync(gitDir)) {
return null;
}
try {
const output = (0, child_process_1.execSync)(`git --git-dir="${gitDir}" log -1 --format="%B" ${commitSha}`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim();
if (!output.length) {
return null;
}
return `Commit ${commitSha.slice(0, 7)}\n${output}`;
}
catch {
return null;
}
}
async function waitForLatestProcess(forge, attemptId, timeoutMs = 45000, pollIntervalMs = 1000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const processes = await forge.listExecutionProcesses(attemptId);
if (Array.isArray(processes) && processes.length > 0) {
return processes[processes.length - 1];
}
}
catch {
// Ignore temporary API failures and keep polling
}
await sleep(pollIntervalMs);
}
return null;
}
async function streamNormalizedLogs(forge, processId, token, timeoutMs = 30000) {
return new Promise((resolve) => {
const wsUrl = forge.getNormalizedLogsStreamUrl(processId);
const headers = { 'User-Agent': 'Genie-MCP/1.0' };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const entryOrder = [];
const entryText = new Map();
const compileTranscript = () => entryOrder
.map((key) => entryText.get(key))
.filter((line) => Boolean(line))
.join('\n') || null;
const upsertEntry = (path, text) => {
if (!text)
return;
if (!entryText.has(path)) {
entryOrder.push(path);
}
entryText.set(path, text);
};
let resolved = false;
const finalize = () => {
if (resolved)
return;
resolved = true;
resolve(compileTranscript());
};
const ws = new ws_1.default(wsUrl, { headers });
const timer = setTimeout(() => {
ws.close();
finalize();
}, timeoutMs);
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (message.JsonPatch && Array.isArray(message.JsonPatch)) {
for (const patch of message.JsonPatch) {
const formatted = formatNormalizedEntry(patch.value);
if (formatted) {
upsertEntry(patch.path || String(entryOrder.length), formatted);
}
}
}
if (message.finished === true) {
clearTimeout(timer);
ws.close();
finalize();
}
}
catch {
upsertEntry(String(entryOrder.length), data.toString());
}
});
ws.on('error', () => {
clearTimeout(timer);
finalize();
});
ws.on('close', () => {
clearTimeout(timer);
finalize();
});
});
}
function formatNormalizedEntry(value) {
if (!value || value.type !== 'NORMALIZED_ENTRY') {
return null;
}
const entryType = value.content?.entry_type?.type;
const content = value.content?.content ?? value.content?.metadata?.text;
if (!content) {
return null;
}
const asString = typeof content === 'string' ? content : JSON.stringify(content);
const prefix = (() => {
switch (entryType) {
case 'assistant_message':
return 'assistant';
case 'user_message':
return 'user';
case 'system_message':
return 'system';
case 'tool_use': {
const toolName = value.content?.entry_type?.tool_name || value.content?.metadata?.name || 'tool';
return `tool:${toolName}`;
}
case 'tool_result': {
const toolName = value.content?.entry_type?.tool_name || value.content?.metadata?.name || 'tool-result';
return `tool-result:${toolName}`;
}
default:
return entryType || 'log';
}
})();
return `${prefix}: ${asString}`;
}
function sleep(durationMs) {
return new Promise(resolve => setTimeout(resolve, durationMs));
}
function buildTextResponse(text) {
return {
content: [{
type: 'text',
text
}]
};
}
// Helper: Get Genie version from package.json
// getGenieVersion(), getVersionHeader(), syncAgentProfilesToForge() imported from ./lib/server-helpers.js
// loadOAuth2Config() imported from ./lib/server-helpers.js
// Load OAuth2 config for HTTP transport
const oauth2Config = (0, server_helpers_js_1.loadOAuth2Config)();
const serverUrl = `http://localhost:${PORT}`;
// Initialize MCP Server using official SDK
const server = new mcp_js_1.McpServer({
name: 'genie',
version: (0, server_helpers_js_1.getGenieVersion)(),
}, {
capabilities: {
logging: {},
tools: {}
// Note: resources capability removed until neuron provider is fully implemented
// See: src/mcp/resources/neuron-provider.ts (provider exists but not wired up)
}
});
// Tool: list_agents - Discover available agents
server.tool('list_agents', 'List all available Genie agents with their capabilities and descriptions. Use this first to discover which agents can help with your task.', async () => {
const agents = (0, server_helpers_js_1.listAgents)();
if (agents.length === 0) {
return { content: [{ type: 'text', text: (0, server_helpers_js_1.getVersionHeader)() + 'No agents found in .genie/code/agents or .genie/create/agents directories.' }] };
}
let response = (0, server_helpers_js_1.getVersionHeader)() + `Found ${agents.length} available agents:\n\n`;
// Group by folder
const grouped = {};
agents.forEach(agent => {
const key = agent.folder || 'core';
if (!grouped[key])
grouped[key] = [];
grouped[key].push(agent);
});
Object.entries(grouped).forEach(([folder, folderAgents]) => {
response += `**${folder}:**\n`;
folderAgents.forEach(agent => {
response += ` • ${agent.displayId}`;
if (agent.name !== agent.displayId)
response += ` (${agent.name})`;
if (agent.description)
response += ` - ${agent.description}`;
response += '\n';
});
response += '\n';
});
response += '\nUse the "task" tool with an agent id and prompt to start an agent task.';
return { content: [{ type: 'text', text: response }] };
});
// Tool: list_tasks - View active and recent tasks
server.tool('list_tasks', 'List active and recent Genie agent tasks. Shows task names, agents, status, and timing. Use this to find tasks to view or continue.', async () => {
const tasks = await (0, server_helpers_js_1.listTasks)();
if (tasks.length === 0) {
return { content: [{ type: 'text', text: (0, server_helpers_js_1.getVersionHeader)() + 'No tasks found. Start a new task with the "task" tool.' }] };
}
let response = (0, server_helpers_js_1.getVersionHeader)() + `Found ${tasks.length} task(s):\n\n`;
tasks.forEach((task, index) => {
const { displayId } = (0, display_transform_js_1.transformDisplayPath)(task.agent);
response += `${index + 1}. **${task.id}** (${task.name})\n`;
response += ` Agent: ${displayId}\n`;
response += ` Status: ${task.status}\n`;
response += ` Created: ${task.created}\n`;
response += ` Last Used: ${task.lastUsed}\n\n`;
});
response += 'Use "view_task" with the task ID (e.g., "c74111b4-...") to see transcript or "continue_task" to send a follow-up.';
return { content: [{ type: 'text', text: response }] };
});
const taskToolShape = {
agent: zod_1.z.string().describe('Agent ID to run (e.g., "plan", "implementor", "debug"). Get available agents from list_agents tool.'),
prompt: zod_1.z.string().describe('Detailed task description for the agent. Be specific about goals, context, and expected outcomes. Agents work best with clear, actionable prompts.'),
name: zod_1.z.string().optional().describe('Friendly task name for easy identification (e.g., "bug-102-fix", "auth-feature"). If omitted, auto-generates: "{agent}-{timestamp}".'),
executor: zod_1.z.string().optional().describe('Override executor key (e.g., "OPENCODE"). Uses agent/default config when omitted.'),
variant: zod_1.z.string().optional().describe('Override executor variant (e.g., "EXPLORE", "WISH"). Derived from agent name when omitted.'),
model: zod_1.z.string().optional().describe('Override model for the selected executor.'),
monitor: zod_1.z.boolean().optional().describe('Set true to wait for completion with WebSocket streaming. Default: false (return immediately).')
};
const TaskToolParams = zod_1.z.object(taskToolShape);
const handleTaskTool = async (args) => {
try {
const resolvedAgentId = (0, agent_resolver_js_1.resolveAgentIdentifier)(args.agent) || args.agent;
const availableAgents = (0, server_helpers_js_1.listAgents)();
let agentSpec;
try {
agentSpec = (0, agent_resolver_js_1.loadAgentSpec)(resolvedAgentId);
}
catch {
const suggestions = availableAgents
.filter(a => a.id.includes(args.agent) || a.displayId.includes(args.agent))
.slice(0, 3)
.map(a => ` • ${a.displayId}`)
.join('\n');
const errorMsg = `❌ **Agent not found:** '${args.agent}'\n\n` +
(suggestions ? `Did you mean:\n${suggestions}\n\n` : '') +
`💡 Use list_agents tool to see all available agents.`;
return buildTextResponse((0, server_helpers_js_1.getVersionHeader)() + errorMsg);
}
const agentGenie = agentSpec.meta?.genie || {};
const agentMeta = agentSpec.meta || {};
const metaExecutor = Array.isArray(agentGenie.executor)
? agentGenie.executor[0]
: agentGenie.executor;
const executorKey = (0, executor_registry_js_1.normalizeExecutorKeyOrDefault)(args.executor || metaExecutor);
// Derive executor variant matching Forge's naming convention
const deriveVariantFromAgentName = (agentPath) => {
// Forge variant naming: CODE_<AGENT_NAME> or CREATE_<AGENT_NAME>
// Examples: code/explore → CODE_EXPLORE, create/writer → CREATE_WRITER
const parts = agentPath.split('/');
const template = parts[0]; // code, create, etc.
// Remove template and category folders (agents/, workflows/)
let remaining = parts.slice(1);
if (remaining.length > 0 && (remaining[0] === 'agents' || remaining[0] === 'workflows')) {
remaining = remaining.slice(1);
}
// Join remaining parts with underscores and uppercase
const agentName = remaining.join('_').toUpperCase();
// Prepend template prefix (CODE_, CREATE_, etc.)
const templatePrefix = template.toUpperCase() + '_';
return templatePrefix + agentName;
};
const executorVariant = (args.variant || // MCP tool parameter (highest priority)
agentMeta.forge_profile_name || // Explicit Forge profile name from frontmatter
agentGenie.executorVariant ||
agentGenie.variant ||
deriveVariantFromAgentName(resolvedAgentId) || // Derive from agent name
'DEFAULT' // Ultimate fallback
).trim().toUpperCase();
const model = args.model || agentMeta.model;
const forgeModule = (0, server_helpers_js_1.loadForgeExecutor)();
if (!forgeModule || typeof forgeModule.createForgeExecutor !== 'function') {
return buildTextResponse((0, server_helpers_js_1.getVersionHeader)() + '❌ Forge executor unavailable (did you run `pnpm run build:genie`?).');
}
const forgeExecutor = forgeModule.createForgeExecutor();
const monitorRequested = Boolean(args.monitor);
const executionMode = monitorRequested ? 'interactive' : 'background';
const sessionResult = await forgeExecutor.createTask({
agentName: resolvedAgentId,
prompt: args.prompt,
executorKey,
executorVariant,
executionMode,
model,
...(args.name?.length ? { name: args.name } : {})
});
const { displayId } = (0, display_transform_js_1.transformDisplayPath)(resolvedAgentId);
const tasksFile = path_1.default.join(WORKSPACE_ROOT, '.genie', 'state', 'tasks.json');
const taskService = new task_service_js_1.TaskService({
paths: { tasksFile }
});
const store = taskService.load();
const now = new Date().toISOString();
store.sessions[sessionResult.attemptId] = {
agent: resolvedAgentId,
taskId: sessionResult.taskId,
projectId: sessionResult.projectId,
executor: executorKey,
executorVariant,
model: model || undefined,
status: monitorRequested ? 'running' : 'background',
created: now,
lastUsed: now,
lastPrompt: args.prompt.slice(0, 200),
forgeUrl: sessionResult.forgeUrl,
background: !monitorRequested
};
await taskService.save(store);
if (!monitorRequested) {
const payload = {
task_id: sessionResult.attemptId,
task_url: sessionResult.forgeUrl,
agent: resolvedAgentId, // Use full agent ID, not display ID
executor: `${executorKey}:${executorVariant}`,
...(model ? { model } : {}),
status: 'started',
message: 'Task running in background'
};
return buildTextResponse((0, server_helpers_js_1.getVersionHeader)() +
`Started background task for ${displayId}.\n` +
`Task ID: ${sessionResult.attemptId}\n` +
`Forge URL: ${sessionResult.forgeUrl}\n\n` +
`**Result JSON:**\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\`\n`);
}
const { baseUrl, token } = (0, service_config_js_1.getForgeConfig)();
const logBuffer = [];
const monitorResult = await (0, task_monitor_js_1.monitorTaskCompletion)({
attemptId: sessionResult.attemptId,
baseUrl,
token,
taskUrl: sessionResult.forgeUrl,
onLog: (line) => {
logBuffer.push(line);
}
});
const outputPayload = {
task_url: monitorResult.task_url,
attempt_id: sessionResult.attemptId,
status: monitorResult.status,
duration_ms: monitorResult.duration_ms,
result: monitorResult.output,
...(monitorResult.error ? { error: monitorResult.error } : {})
};
const updatedStore = taskService.load();
if (updatedStore.sessions[sessionResult.attemptId]) {
updatedStore.sessions[sessionResult.attemptId].status = monitorResult.status;
updatedStore.sessions[sessionResult.attemptId].lastUsed = new Date().toISOString();
updatedStore.sessions[sessionResult.attemptId].background = false;
updatedStore.sessions[sessionResult.attemptId].lastPrompt = args.prompt.slice(0, 200);
}
await taskService.save(updatedStore);
const recentLogs = logBuffer.slice(-40).join('\n');
let response = (0, server_helpers_js_1.getVersionHeader)();
response += `Started interactive task for ${displayId}.\n`;
response += `Task ID: ${sessionResult.attemptId}\n`;
response += `Forge URL: ${sessionResult.forgeUrl}\n`;
response += `Status: ${monitorResult.status}\n\n`;
response += `**Result JSON:**\n\`\`\`json\n${JSON.stringify(outputPayload, null, 2)}\n\`\`\`\n`;
if (recentLogs) {
response += '\n**Recent Logs:**\n\`\`\`\n' + recentLogs + '\n\`\`\`\n';
}
response += '\nUse list_tasks to see task history, or view_task/continue_task for follow-ups.';
return buildTextResponse(response);
}
catch (error) {
const message = error?.message || String(error);
return buildTextResponse((0, server_helpers_js_1.getVersionHeader)() + `❌ Error starting task: ${message}`);
}
};
// Tool: task - Start a new agent task (headless by default)
server.tool('task', 'Start a new Genie agent task. Returns immediately when monitor=false, or streams live progress when monitor=true.', taskToolShape, handleTaskTool);
// Backward-compatible alias
server.tool('run', '[Deprecated] Alias for task tool (use mcp__genie__task).', taskToolShape, handleTaskTool);
const continueTaskShape = {
task_id: zod_1.z.string().optional().describe('Task attempt ID to resume (preferred snake_case, e.g., "c74111b4-...").'),
taskId: zod_1.z.string().optional().describe('Legacy camelCase alias for task_id.'),
prompt: zod_1.z.string().describe('Follow-up message or question for the agent. Build on the previous conversation context.')
};
const ContinueTaskParams = zod_1.z.object(continueTaskShape).refine((data) => Boolean((data.task_id ?? data.taskId)?.trim()), { message: 'task_id (or taskId) is required' });
const handleContinueTask = async (args) => {
try {
const taskId = (args.task_id || args.taskId || '').trim();
const cliArgs = ['resume', taskId];
if (args.prompt?.length) {
cliArgs.push(args.prompt);
}
const { stdout, stderr } = await (0, cli_executor_js_1.runCliCommand)(WORKSPACE_ROOT, cliArgs, 120000);
const output = stdout + (stderr ? `\n\nStderr:\n${stderr}` : '');
return buildTextResponse((0, server_helpers_js_1.getVersionHeader)() + `Resumed task ${taskId}:\n\n${output}`);
}
catch (error) {
return buildTextResponse((0, server_helpers_js_1.getVersionHeader)() + (0, cli_executor_js_1.formatCliFailure)('resume task', error));
}
};
server.tool('continue_task', 'Resume an existing agent task with a follow-up prompt. Use this to continue conversations, provide additional context, or ask follow-up questions to an agent.', continueTaskShape, handleContinueTask);
const viewTaskShape = {
taskId: zod_1.z.string().describe('Task ID to view (get from list_tasks tool). Example: "c74111b4-1a81-49d9-b7d3-d57e31926710"'),
full: zod_1.z.boolean().optional().default(false).describe('Show full transcript (true) or recent messages only (false). Default: false.')
};
const ViewTaskParams = zod_1.z.object(viewTaskShape);
const handleViewTask = async (args) => {
try {
const result = await viewSession(args.taskId);
if (result.error) {
return buildTextResponse((0, server_helpers_js_1.getVersionHeader)() + `❌ Error viewing task:\n\n${result.error}`);
}
let response = (0, server_helpers_js_1.getVersionHeader)();
response += `**Task:** ${args.taskId}\n`;
response += `**Status:** ${result.status}\n\n`;
if (result.transcript) {
const lines = result.transcript.split('\n');
const displayLines = args.full ? lines : lines.slice(-200);
response += `**Transcript:**\n\`\`\`\n${displayLines.join('\n')}\n\`\`\``;
}
else {
response += `**Transcript:** (No logs available yet)`;
}
return buildTextResponse(response);
}
catch (error) {
return buildTextResponse((0, server_helpers_js_1.getVersionHeader)() + `❌ Error: ${error.message}`);
}
};
server.tool('view_task', 'View the transcript of an agent task. Shows the conversation history, agent outputs, and any artifacts generated. Use full=true for complete transcript or false for recent messages only.', viewTaskShape, handleViewTask);
server.tool('view', '[Deprecated] Alias for view_task (use mcp__genie__view_task).', viewTaskShape, handleViewTask);
// Tool: stop - Terminate a running task
server.tool('stop', 'Stop a running agent task. Use this to terminate long-running agents or cancel tasks that are no longer needed. The task state is preserved for later viewing.', {
taskId: zod_1.z.string().optional().describe('Task name to stop (get from list_tasks tool). Example: "146-task-name-architecture"'),
sessionId: zod_1.z.string().optional().describe('Legacy alias for taskId.')
}, async (args) => {
try {
const taskId = (args.taskId || args.sessionId || '').trim();
if (!taskId) {
return { content: [{ type: 'text', text: (0, server_helpers_js_1.getVersionHeader)() + '❌ Error: taskId or sessionId is required.' }] };
}
const { stdout, stderr } = await (0, cli_executor_js_1.runCliCommand)(WORKSPACE_ROOT, ['stop', taskId], 30000);
const output = stdout + (stderr ? `\n\nStderr:\n${stderr}` : '');
return { content: [{ type: 'text', text: (0, server_helpers_js_1.getVersionHeader)() + `Stopped task ${taskId}:\n\n${output}` }] };
}
catch (error) {
return { content: [{ type: 'text', text: (0, server_helpers_js_1.getVersionHeader)() + (0, cli_executor_js_1.formatCliFailure)('stop task', error) }] };
}
});
// Helper: List all spell files in a directory recursively
// listSpellsInDir(), readSpellContent() imported from ./lib/spell-utils.js
// Tool: list_spells - Discover available spells
server.tool('list_spells', 'List all available Genie spells (reusable knowledge patterns). Returns spells from .genie/spells/ (global), .genie/code/spells/ (code-specific), and .genie/create/spells/ (create-specific).', async () => {
// Note: Scope parameter removed - always returns all spells for simplicity (avoids MCP schema validation issues)
const scope = 'all';
const result = {};
// Global spells
if (scope === 'all' || scope === 'global') {
const globalSpellsDir = path_1.default.join(WORKSPACE_ROOT, '.genie', 'spells');
result.global = (0, spell_utils_js_1.listSpellsInDir)(globalSpellsDir);
}
// Code spells
if (scope === 'all' || scope === 'code') {
const codeSpellsDir = path_1.default.join(WORKSPACE_ROOT, '.genie', 'code', 'spells');
result.code = (0, spell_utils_js_1.listSpellsInDir)(codeSpellsDir);
}
// Create spells
if (scope === 'all' || scope === 'create') {
const createSpellsDir = path_1.default.join(WORKSPACE_ROOT, '.genie', 'create', 'spells');
result.create = (0, spell_utils_js_1.listSpellsInDir)(createSpellsDir);
}
// Format output
let output = (0, server_helpers_js_1.getVersionHeader)() + '# Genie Spells\n\n';
if (result.global) {
output += `## Global Spells (.genie/spells/) - ${result.global.length} spells\n`;
output += 'Universal patterns applicable to all collectives:\n\n';
for (const spell of result.global) {
output += `- **${spell.name}** - \`${spell.path}\`\n`;
}
output += '\n';
}
if (result.code) {
output += `## Code Spells (.genie/code/spells/) - ${result.code.length} spells\n`;
output += 'Code-specific patterns for technical execution:\n\n';
for (const spell of result.code) {
output += `- **${spell.name}** - \`${spell.path}\`\n`;
}
output += '\n';
}
if (result.create) {
output += `## Create Spells (.genie/create/spells/) - ${result.create.length} spells\n`;
output += 'Create-specific patterns for creative work:\n\n';
for (const spell of result.create) {
output += `- **${spell.name}** - \`${spell.path}\`\n`;
}
output += '\n';
}
const totalCount = (result.global?.length || 0) + (result.code?.length || 0) + (result.create?.length || 0);
output += `\n**Total:** ${totalCount} spells\n`;
return { content: [{ type: 'text', text: output }] };
});
// normalizeSpellPath() imported from ./lib/spell-utils.js
// Tool: read_spell - Read specific spell content
server.tool('read_spell', 'Read the full content of a specific spell. Returns the spell content after the frontmatter (---). Use list_spells first to see available spells. Supports multiple path formats: "spells/learn.md", ".genie/spells/learn.md", "code/spells/debug.md", or just "learn" (searches all directories).', {
spell_path: zod_1.z.string().describe('Path to spell file. Flexible formats supported: "spells/learn.md" (recommended), ".genie/spells/learn.md" (auto-strips .genie/), "code/spells/debug.md", or just "learn" (auto-searches and adds .md extension)')
}, async (args) => {
const normalizedPath = (0, spell_utils_js_1.normalizeSpellPath)(args.spell_path);
const fullPath = path_1.default.join(WORKSPACE_ROOT, '.genie', normalizedPath);
try {
const content = (0, spell_utils_js_1.readSpellContent)(fullPath);
return { content: [{ type: 'text', text: (0, server_helpers_js_1.getVersionHeader)() + `# Spell: ${normalizedPath}\n\n${content}` }] };
}
catch (error) {
return { content: [{ type: 'text', text: (0, server_helpers_js_1.getVersionHeader)() + `Error reading spell: ${error.message}` }] };
}
});
// Workflows have been merged into spells - no separate workflow tools needed
// Tool: get_workspace_info - Get workspace metadata (lean self-awareness data)
server.tool('get_workspace_info', 'Get essential workspace info for agent self-awareness: project name, tech stack, current branch, and available commands. Lightweight context for agents to understand their environment.', async () => {
try {
// Extract essential data only (no bloat)
const workspaceInfo = {};
// 1. Project name (from package.json or directory name)
const packageJsonPath = path_1.default.join(WORKSPACE_ROOT, 'package.json');
if (fs_1.default.existsSync(packageJsonPath)) {
const pkg = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf-8'));
workspaceInfo.project = pkg.name || path_1.default.basename(WORKSPACE_ROOT);
}
else {
workspaceInfo.project = path_1.default.basename(WORKSPACE_ROOT);
}
// 2. Tech stack (runtime, language, package manager)
workspaceInfo.techStack = {};
if (fs_1.default.existsSync(packageJsonPath)) {
const pkg = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf-8'));
workspaceInfo.techStack.runtime = 'Node.js';
workspaceInfo.techStack.packageManager = fs_1.default.existsSync(path_1.default.join(WORKSPACE_ROOT, 'pnpm-lock.yaml')) ? 'pnpm' :
fs_1.default.existsSync(path_1.default.join(WORKSPACE_ROOT, 'yarn.lock')) ? 'yarn' : 'npm';
// Detect language from dependencies
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript) {
workspaceInfo.techStack.language = 'TypeScript';
}
else {
workspaceInfo.techStack.language = 'JavaScript';
}
}
// 3. Current branch (from git)
try {
const { execSync } = require('child_process');
const branch = execSync('git branch --show-current', { cwd: WORKSPACE_ROOT, encoding: 'utf-8' }).trim();
workspaceInfo.currentBranch = branch || 'unknown';
}
catch {
workspaceInfo.currentBranch = 'unknown';
}
// 4. Available commands (from package.json scripts)
workspaceInfo.commands = {};
if (fs_1.default.existsSync(packageJsonPath)) {
const pkg = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf-8'));
if (pkg.scripts) {
// Only include common/useful commands (build, test, lint)
const relevantScripts = ['build', 'test', 'lint', 'dev', 'start'];
for (const script of relevantScripts) {
if (pkg.scripts[script]) {
workspaceInfo.commands[script] = `${workspaceInfo.techStack.packageManager} run ${script}`;
}
}
// Also include genie-specific commands
Object.keys(pkg.scripts).forEach(script => {
if (script.includes('genie') || script.includes('test:')) {
workspaceInfo.commands[script] = `${workspaceInfo.techStack.packageManager} run ${script}`;
}
});
}
}
// Format lean output
let output = (0, server_helpers_js_1.getVersionHeader)();
output += `**Project:** ${workspaceInfo.project}\n`;
output += `**Branch:** ${workspaceInfo.currentBranch}\n`;
output += `**Tech Stack:** ${workspaceInfo.techStack.language} + ${workspaceInfo.techStack.runtime} (${workspaceInfo.techStack.packageManager})\n\n`;
if (Object.keys(workspaceInfo.commands).length > 0) {
output += `**Available Commands:**\n`;
Object.entries(workspaceInfo.commands).forEach(([name, cmd]) => {
output += `- ${name}: \`${cmd}\`\n`;
});
}
return { content: [{ type: 'text', text: output }] };
}
catch (error) {
return { content: [{ type: 'text', text: (0, server_helpers_js_1.getVersionHeader)() + `Error gathering workspace info: ${error.message}` }] };
}
});
// ============================================================================
// WEBSOCKET-NATIVE TOOLS (MVP Phase 6) - Real-time streaming + git validation
// ============================================================================
// Tool: transform_prompt - Synchronous prompt transformer (no worktree, no git validation)
server.tool('transform_prompt', 'Transform/enhance a prompt using an agent synchronously. Runs in current workspace (no worktree). Modern equivalent of old "background off" mode.', {
prompt: zod_1.z.string().describe('Prompt to transform/enhance (e.g., "Help me write a better prompt for implementing dark mode")'),
agent: zod_1.z.string().optional().default('prompt').describe('Agent to use for transformation (default: "prompt")')
}, {
readOnlyHint: true
}, async (args, extra) => {
const { sessionId } = extra;
// Use official MCP SDK logging for real-time streaming
await (0, prompt_tool_js_1.executePromptTool)(args, {
streamContent: async (chunk) => {
// Stream content via MCP logging notifications
// IMPORTANT: MCP routing uses sessionId (from ToolContext). Do not replace with taskId. (important-comment)
await server.sendLoggingMessage({
level: "info",
data: chunk
}, sessionId);
}
});
return { content: [{ type: 'text', text: 'Prompt transformation completed. Check the logs above for details.' }] };
});
// Tool: continue_task - Send follow-up work to existing task (skip if already registered earlier)
try {
server.tool('continue_task', 'Send follow-up work to an existing task attempt. Used primarily by master orchestrators to receive new work.', {
attempt_id: zod_1.z.string().describe('Task attempt ID to send work to'),
prompt: zod_1.z.string().describe('Follow-up prompt with new work')
}, async (args, extra) => {
const { sessionId } = extra;
await (0, continue_task_tool_js_1.executeContinueTaskTool)(args, {
streamContent: async (chunk) => {
// IMPORTANT: MCP routing uses sessionId (from ToolContext). Do not replace with taskId.
await server.sendLoggingMessage({
level: "info",
data: chunk
}, sessionId);
}
});
return { content: [{ type: 'text', text: 'Follow-up sent successfully. Check the logs above for details.' }] };
});
}
catch (error) {
if (!String(error?.message || '').includes('already registered')) {
throw error;
}
}
// Tool: create_subtask - Create child task under master orchestrator
server.tool('create_subtask', 'Create a child task under a master orchestrator. Allows masters to delegate work as subtasks.', {
parent_attempt_id: zod_1.z.string().describe('Parent task attempt ID (the master orchestrator)'),
title: zod_1.z.string().describe('Subtask title'),
prompt: zod_1.z.string().describe('Subtask prompt/description'),
executor: zod_1.z.string().optional().default('CLAUDE_CODE:DEFAULT').describe('Executor variant (e.g., "CLAUDE_CODE:wish", "CLAUDE_CODE:DEFAULT")')
}, async (args, extra) => {
const { sessionId } = extra;
await (0, create_subtask_tool_js_1.executeCreateSubtaskTool)(args, {
streamContent: async (chunk) => {
// IMPORTANT: MCP routing uses sessionId (from ToolContext). Do not replace with taskId. (important-comment)
await server.sendLoggingMessage({
level: "info",
data: chunk
}, sessionId);
}
});
return { content: [{ type: 'text', text: 'Subtask created successfully. Check the logs above for details.' }] };
});
// Detect agent role (neuron architecture)
const roleInfo = (0, role_detector_js_1.detectGenieRole)();
const readOnly = (0, role_detector_js_1.isReadOnlyFilesystem)(roleInfo.role);
// Debug mode detection
const debugMode = process.env.MCP_DEBUG === '1' || process.env.DEBUG === '1';
// ============================================================================
// MCP RESOURCES - Neuron thought streams (real-time)
// ============================================================================
// Initialize neuron provider if Forge available
let neuronProvider = null;
try {
const { baseUrl: FORGE_URL } = (0, service_config_js_1.getForgeConfig)();
const PROJECT_ID = process.env.PROJECT_ID || process.env.FORGE_PROJECT_ID;
if (PROJECT_ID) {
neuronProvider = (0, neuron_provider_js_1.initNeuronProvider)(FORGE_URL, PROJECT_ID);
if (debugMode) {
console.error('✅ Neuron provider initialized (wish, forge, review, master)');
}
}
else {
if (debugMode) {
console.error('⚠️ Neuron resources unavailable (PROJECT_ID not set)');
}
}
}
catch (error) {
if (debugMode) {
console.error(`⚠️ Neuron provider initialization failed: ${error.message}`);
}
}
// Register MCP resource handlers (using server request handling pattern)
// Note: MCP SDK resources API is still evolving - this is Phase 2 foundation
// Full resource subscription will be completed in Phase 3 once SDK stabilizes
// ============================================================================
// CLEANUP HANDLERS - Prevent zombie processes (Fix: MCP server proliferation)
// ============================================================================
let isShuttingDown = false;
let serverConnection = null; // Store server connection for cleanup
/**
* Keep the stdio transport alive until the connection explicitly closes.
* Prevents Node from exiting immediately after startup (regression: MCP server
* would launch and exit before Genie CLI could stay alive).
*/
function waitForStdioTransportShutdown(transport) {
return new Promise((resolve) => {
const previousOnClose = transport.onclose;
const previousOnError = transport.onerror;
const finalize = (reason) => {
transport.onclose = previousOnClose;
transport.onerror = previousOnError;
if (debugMode) {
console.error(`MCP stdio transport stopping (${reason})`);
}
resolve();
};
transport.onclose = () => {
if (typeof previousOnClose === 'function') {
previousOnClose();
}
finalize('close');
};
transport.onerror = (error) => {
if (typeof previousOnError === 'function') {
previousOnError(error);
}
finalize(`error: ${error instanceof Error ? error.message : String(error)}`);
};
// Ensure the stream remains in flowing mode so Node keeps the event loop alive.
if (typeof process.stdin.resume === 'function') {
process.stdin.resume();
}
});
}
async function gracefulShutdown(signal) {
if (isShuttingDown)
return;
isShuttingDown = true;
if (debugMode) {
console.error(`\n📡 Received ${signal}, shutting down MCP server gracefully...`);
}
try {
// Close server connection if exists
if (serverConnection && typeof serverConnection.close === 'function') {
await serverConnection.close();
}
// Give pending operations time to finish (max 2s)
await new Promise(resolve => setTimeout(resolve, 2000));
if (debugMode) {
console.error('✅ MCP server shutdown complete');
}
}
catch (error) {
console.error(`⚠️ Error during shutdown: ${error}`);
}
finally {
process.exit(0);
}
}
// Register signal handlers for all termination signals
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGHUP', () => gracefulShutdown('SIGHUP'));
// Handle uncaught errors
process.on('uncaughtException', (error) => {
console.error(`❌ Uncaught exception: ${error.message}`);
gracefulShutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error(`❌ Unhandled rejection: ${reason}`);
gracefulShutdown('unhandledRejection');
});
// Parent process monitor (stdio mode only)
// When Claude Code (parent) closes, stdin pipe closes and process should exit
if (TRANSPORT === 'stdio') {
process.stdin.on('end', () => {
if (debugMode) {
console.error('📡 Parent closed stdin, shutting down...');
}
gracefulShutdown('stdin-end');
});
process.stdin.on('close', () => {
if (debugMode) {
console.error('📡 stdin closed, shutting down...');
}
gracefulShutdown('stdin-close');
});
}
// Start server with configured transport (only log in debug mode)
if (debugMode) {
// Verbose startup logs (debug mode only)
console.error('Starting Genie MCP Server (MVP)...');
console.error(`Version: ${(0, server_helpers_js_1.getGenieVersion)()}`);
console.error(`Transport: ${TRANSPORT}`);
console.error(`Role: ${roleInfo.role} (${roleInfo.confidence} confidence, method: ${roleInfo.method})`);
if (readOnly) {
console.error('🔒 Filesystem: READ-ONLY (master orchestrator)');
}
if (roleInfo.branch) {
console.error(`Branch: ${roleInfo.branch}`);
}
if (roleInfo.worktree) {
console.error(`Worktree: ${roleInfo.worktree}`);
}
// Dynamically count tools instead of hardcoding
const coreTools = ['list_agents', 'list_tasks', 'run', 'view', 'stop', 'list_spells', 'read_spell', 'get_workspace_info'];
const wsTools = ['transform_prompt'];
const neuronTools = ['continue_task', 'create_subtask'];
const totalTools = coreTools.length + wsTools.length + neuronTools.length;
console.error(`Tools: ${totalTools} total`);
console.error(` - ${coreTools.length} core (agents, tasks, spells, workspace)`);
console.error(` - ${wsTools.length} WebSocket-native (transform_prompt)`);
console.error(` - ${neuronTools.length} neuron (continue_task, create_subtask)`);
console.error('WebSocket: Real-time streaming enabled');
console.error('');
}
// Check for existing server and cleanup orphans
(async () => {
// Cleanup orphaned servers on startup
const cleanupResult = await (0, process_cleanup_js_1.cleanupStaleMcpServers)({
killOrphans: true,
dryRun: false
});
if (debugMode && cleanupResult.orphans > 0) {
console.error(`🧹 Cleaned up ${cleanupResult.killed} orphaned MCP server(s)`);
if (cleanupResult.failed > 0) {
console.error(`⚠️ Failed to kill ${cleanupResult.failed} process(es)`);
}
}
// NOTE: PID file conflict check moved to server-manager.ts (with takeover prompt)
// This allows user-friendly takeover instead of immediate exit
// Write PID file ONLY for HTTP/SSE transport (singleton per port)
// Stdio transport allows multiple instances (no PID file needed)
if (TRANSPORT === 'httpStream' || TRANSPORT === 'http') {
(0, process_cleanup_js_1.writePidFile)(WORKSPACE_ROOT);
}
})();
// Forge sync (always show, one line)
process.stderr.write('🔄 Syncing agent profiles...');
// Sync agents before starting server (async but non-blocking)
// Note: forge-executor.ts handles the completion message, so we don't print anything here
(0, server_helpers_js_1.syncAgentProfilesToForge)()
.then(() => {
// forge-executor.ts already printed the completion message
// No additional output needed (prevents duplicate checkmarks)
})
.catch(err => {
console.warn(`\n⚠️ Agent sync failed: ${err.message}`);
});
(async () => {
if (TRANSPORT === 'stdio') {
const transport = new stdio_js_1.StdioServerTransport();
await server.connect(transport);
serverConnection = transport;
console.error('✅ Server started successfully (stdio)');
console.error('Ready for Claude Desktop or MCP Inspector connections');
// Keep process alive until the transport signals shutdown.
await waitForStdioTransportShutdown(transport);
}
else if (TRANSPORT === 'httpStream' || TRANSPORT === 'http') {
// HTTP Stream transport with OAuth2 authentication
if (!oauth2Config) {
console.error('❌ OAuth2 config not found. Cannot start HTTP server.');
console.error('Run `genie mcp configure` to set up OAuth2.');
process.exit(1);
}
if (debugMode) {
console.error(`Starting Genie MCP Server v${(0, server_helpers_js_1.getGenieVersion)()} (HTTP Stream)...`);
console.error(`Port: ${PORT}`);
}
// Use http-server.ts (Express + SDK StreamableHTTPServerTransport + OAuth)
await (0, http_server_js_1.startHttpServer)({
server,
oauth2Config,
port: PORT,
onReady: (url) => {
// http-server.ts already prints the success message
// This callback is kept for backwards compatibility
}
});
}
else {
console.error(`❌ Unknown transport type: ${TRANSPORT}`);
console.error('Set MCP_TRANSPORT to "stdio" or "httpStream"');
process.exit(1);
}
})().catch((error) => {
console.error('❌ Server startup failed:', error);
process.exit(1);
});