router-mcp-server
Version:
Execution engine for GAFF workflows - executes intent graphs with memory-backed state management, parallel execution, and HITL support
630 lines โข 26.6 kB
JavaScript
/**
* Router MCP Server - The Execution Engine
*
* Executes intent graphs by orchestrating agents, managing state via memory,
* and coordinating HITL (Human-in-the-Loop) interactions.
*
* Part of GAFF (Graphical Agentic Flow Framework)
* Author: Sean Poyner <sean.poyner@pm.me>
*/
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 { getMemoryClient } from './utils/memory-client.js';
import { validateDAG, topologicalSort, groupNodesForParallelExecution, resolveNodeInput, isHITLNode, } from './utils/graph-executor.js';
import { routeToAgent } from './utils/agent-router.js';
import { validateSafetyPre, validateQualityPost, validateSafetyPost, logAuditEntry } from './utils/quality-safety-hooks.js';
/**
* MCP Tools Definition
*/
const tools = [
{
name: 'execute_graph',
description: 'Execute an intent graph by routing to agents in correct order with memory-based state management',
inputSchema: {
type: 'object',
properties: {
graph: {
type: 'object',
description: 'Intent graph to execute',
},
graph_memory_key: {
type: 'string',
description: 'Memory key to retrieve intent graph from memory MCP server',
},
execution_mode: {
type: 'string',
enum: ['sync', 'async'],
description: 'Execution mode (default: sync)',
},
context: {
type: 'object',
description: 'Initial context/variables for execution',
},
config: {
type: 'object',
properties: {
max_parallel: { type: 'number', description: 'Max parallel nodes (default: 5)' },
timeout_ms: { type: 'number', description: 'Overall timeout (default: 300000)' },
enable_quality_check: { type: 'boolean', description: 'Enable quality checking (default: false)' },
enable_hitl: { type: 'boolean', description: 'Enable HITL pausing (default: true)' },
max_retries: { type: 'number', description: 'Max node retries (default: 3)' },
store_state_in_memory: { type: 'boolean', description: 'Store execution state in memory (default: true)' },
},
},
quality_requirements: {
type: 'object',
description: 'Quality validation requirements for automatic quality checks and rerun logic',
},
safety_requirements: {
type: 'object',
description: 'Safety and compliance requirements for validation and audit logging',
},
orchestration_card: {
type: 'object',
description: 'Original orchestration card (required for compliance validation)',
},
},
},
},
{
name: 'route_to_agent',
description: 'Route a single request to a specific agent',
inputSchema: {
type: 'object',
properties: {
agent_name: { type: 'string' },
tool_name: { type: 'string' },
input: { type: 'object' },
timeout_ms: { type: 'number' },
retry_config: {
type: 'object',
properties: {
max_attempts: { type: 'number' },
backoff_strategy: { type: 'string', enum: ['linear', 'exponential'] },
},
},
},
required: ['agent_name', 'tool_name', 'input'],
},
},
{
name: 'get_execution_status',
description: 'Get current status of execution from memory',
inputSchema: {
type: 'object',
properties: {
execution_id: { type: 'string' },
},
required: ['execution_id'],
},
},
{
name: 'pause_execution',
description: 'Pause running execution',
inputSchema: {
type: 'object',
properties: {
execution_id: { type: 'string' },
reason: { type: 'string' },
},
required: ['execution_id', 'reason'],
},
},
{
name: 'resume_execution',
description: 'Resume paused execution',
inputSchema: {
type: 'object',
properties: {
execution_id: { type: 'string' },
approval_decision: {
type: 'object',
properties: {
approved: { type: 'boolean' },
modified_context: { type: 'object' },
},
},
},
required: ['execution_id'],
},
},
{
name: 'cancel_execution',
description: 'Cancel running or paused execution',
inputSchema: {
type: 'object',
properties: {
execution_id: { type: 'string' },
reason: { type: 'string' },
},
required: ['execution_id', 'reason'],
},
},
];
/**
* Generate unique execution ID
*/
function generateExecutionId() {
return `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Main Router Server Class
*/
class RouterServer {
server;
memoryClient = getMemoryClient();
constructor() {
this.server = new Server({
name: 'router-mcp-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
this.setupHandlers();
this.setupErrorHandling();
}
setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools,
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'execute_graph':
return await this.handleExecuteGraph(args);
case 'route_to_agent':
return await this.handleRouteToAgent(args);
case 'get_execution_status':
return await this.handleGetExecutionStatus(args);
case 'pause_execution':
return await this.handlePauseExecution(args);
case 'resume_execution':
return await this.handleResumeExecution(args);
case 'cancel_execution':
return await this.handleCancelExecution(args);
default:
throw new Error(`Unknown tool: ${name}`);
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[Router] Error in ${name}:`, errorMessage);
// For execute_graph, return proper execution result format even on error
if (name === 'execute_graph') {
return {
content: [
{
type: 'text',
text: JSON.stringify({
execution_id: generateExecutionId(),
status: 'failed',
results: {},
execution_time_ms: 0,
nodes_executed: 0,
nodes_failed: [],
context: {},
error: errorMessage,
}, null, 2),
},
],
};
}
// For other tools, return error format
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: false,
error: errorMessage,
}),
},
],
};
}
});
}
/**
* Execute Intent Graph
*/
async handleExecuteGraph(args) {
const { graph, graph_memory_key, execution_mode = 'sync', context = {}, config = {}, } = args;
// TODO: If graph_memory_key provided, retrieve graph from memory
const intentGraph = graph || {};
const executionConfig = {
max_parallel: config.max_parallel || 5,
timeout_ms: config.timeout_ms || 300000,
enable_quality_check: config.enable_quality_check || false,
enable_hitl: config.enable_hitl !== undefined ? config.enable_hitl : true,
max_retries: config.max_retries || 3,
store_state_in_memory: config.store_state_in_memory !== undefined ? config.store_state_in_memory : true,
};
console.error('[Router] Starting graph execution');
console.error(`[Router] Nodes: ${intentGraph.nodes?.length || 0}`);
console.error(`[Router] Config:`, JSON.stringify(executionConfig));
// Validate DAG
const validation = validateDAG(intentGraph);
if (!validation.valid) {
throw new Error(`Invalid graph: ${validation.error}`);
}
// PRE-EXECUTION SAFETY VALIDATION
if (args.safety_requirements?.enabled) {
console.error('[Router] ๐ Running pre-execution safety checks...');
const safetyCheck = await validateSafetyPre(context, args.safety_requirements, args.orchestration_card);
if (!safetyCheck.passed) {
throw new Error(`โ Safety validation failed: ${safetyCheck.errors.join(', ')}`);
}
console.error('[Router] โ
Pre-execution safety checks passed');
}
const execution_id = generateExecutionId();
const startTime = Date.now();
// Initialize execution state
const executionState = {
execution_id,
status: 'running',
graph: intentGraph,
current_node: null,
completed_nodes: [],
failed_nodes: [],
results: {},
context,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// Store initial state in memory
if (executionConfig.store_state_in_memory) {
await this.memoryClient.storeExecutionState(executionState);
}
// Perform topological sort
const sortedNodes = topologicalSort(intentGraph);
console.error('[Router] Topological order:', sortedNodes);
// Group nodes for parallel execution if enabled
const executionGroups = executionConfig.max_parallel > 1
? groupNodesForParallelExecution(intentGraph, sortedNodes)
: sortedNodes.map(nodeId => [nodeId]);
console.error(`[Router] Execution groups: ${executionGroups.length}`);
const results = {};
const failedNodes = [];
// Execute node groups
for (let groupIdx = 0; groupIdx < executionGroups.length; groupIdx++) {
const group = executionGroups[groupIdx];
console.error(`[Router] Executing group ${groupIdx + 1}/${executionGroups.length} with ${group.length} node(s)`);
// Execute nodes in parallel within group
const groupPromises = group.map(async (nodeId) => {
const node = intentGraph.nodes.find(n => (n.id || n.node_id) === nodeId);
if (!node) {
throw new Error(`Node not found: ${nodeId}`);
}
// Update current node
executionState.current_node = nodeId;
if (executionConfig.store_state_in_memory) {
await this.memoryClient.storeExecutionState(executionState);
}
// Check for HITL
if (executionConfig.enable_hitl && isHITLNode(node)) {
console.error(`[Router] HITL node detected: ${nodeId} - PAUSING`);
executionState.status = 'paused_for_approval';
executionState.paused_at_node = nodeId;
executionState.paused_at = new Date().toISOString();
if (executionConfig.store_state_in_memory) {
await this.memoryClient.storeExecutionState(executionState);
}
throw new Error('HITL_PAUSE'); // Special error to break execution
}
// Resolve input variables
const resolvedInput = resolveNodeInput(node, results, executionState.context);
// Apply global timeout if node doesn't have one
if (!node.timeout_ms && executionConfig.timeout_ms) {
node.timeout_ms = executionConfig.timeout_ms;
}
// Execute node
const nodeResult = await routeToAgent(node, resolvedInput);
// Store result
results[nodeId] = nodeResult;
executionState.results[nodeId] = nodeResult;
if (nodeResult.success) {
executionState.completed_nodes.push(nodeId);
// Store node result in memory
if (executionConfig.store_state_in_memory) {
await this.memoryClient.storeNodeResult(execution_id, nodeId, nodeResult.result);
}
}
else {
executionState.failed_nodes.push(nodeId);
failedNodes.push(nodeId);
}
// Update state
if (executionConfig.store_state_in_memory) {
await this.memoryClient.storeExecutionState(executionState);
}
return nodeResult;
});
try {
await Promise.all(groupPromises);
}
catch (error) {
if (error.message === 'HITL_PAUSE') {
// HITL pause - return paused state
return {
content: [
{
type: 'text',
text: JSON.stringify({
execution_id,
status: 'paused_for_approval',
paused_at_node: executionState.paused_at_node,
waiting_for_approval: true,
message: 'Execution paused for human approval',
resume_instructions: `Call resume_execution with execution_id="${execution_id}"`,
partial_results: results,
}, null, 2),
},
],
};
}
throw error;
}
}
const executionTime = Date.now() - startTime;
let status = failedNodes.length > 0 ? 'failed' : 'completed';
executionState.status = status;
executionState.updated_at = new Date().toISOString();
if (executionConfig.store_state_in_memory) {
await this.memoryClient.storeExecutionState(executionState);
}
// POST-EXECUTION QUALITY VALIDATION
let qualityResult = null;
if (args.quality_requirements?.enabled && args.quality_requirements?.auto_validate && status === 'completed') {
console.error('[Router] ๐ Running post-execution quality checks...');
qualityResult = await validateQualityPost({ execution_id, status, results, execution_time_ms: executionTime }, args.quality_requirements, intentGraph);
console.error(`[Router] Quality score: ${qualityResult.quality_score.toFixed(2)}`);
// AUTOMATIC RERUN IF QUALITY FAILS
if (qualityResult.rerun_required && args.quality_requirements.rerun_strategy !== 'none') {
const attemptCount = (args._rerun_attempt || 0) + 1;
const maxAttempts = args.quality_requirements.max_rerun_attempts || 2;
if (attemptCount <= maxAttempts) {
console.error(`[Router] โ ๏ธ Quality check failed. Rerunning (attempt ${attemptCount}/${maxAttempts})...`);
// Recursive rerun with attempt tracking
return await this.handleExecuteGraph({
...args,
_rerun_attempt: attemptCount
});
}
else {
console.error(`[Router] โ Max rerun attempts reached (${maxAttempts})`);
status = 'failed_quality';
}
}
else if (qualityResult.is_acceptable) {
console.error('[Router] โ
Quality checks passed');
}
}
// POST-EXECUTION SAFETY VALIDATION
let sanitizedResults = results;
if (args.safety_requirements?.enabled && status === 'completed') {
console.error('[Router] ๐ Running post-execution safety checks...');
const outputCheck = await validateSafetyPost(results, args.safety_requirements);
if (outputCheck.sanitized_data) {
sanitizedResults = outputCheck.sanitized_data;
console.error('[Router] ๐งน Output sanitized');
}
if (outputCheck.passed) {
console.error('[Router] โ
Output safety checks passed');
}
else {
console.error(`[Router] โ ๏ธ Output safety issues: ${outputCheck.errors.join(', ')}`);
}
// AUDIT LOGGING
if (args.safety_requirements.audit_logging) {
console.error('[Router] ๐ Creating audit log entry...');
await logAuditEntry(execution_id, args.context?.user_id || 'unknown', qualityResult?.quality_score, args.safety_requirements.compliance_standards);
console.error('[Router] โ
Audit log created');
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
execution_id,
status,
results: sanitizedResults,
execution_time_ms: executionTime,
nodes_executed: Object.keys(results).length,
nodes_failed: failedNodes,
context: executionState.context,
// Quality & Safety metadata
quality_validation: qualityResult ? {
quality_score: qualityResult.quality_score,
is_acceptable: qualityResult.is_acceptable,
issues: qualityResult.issues,
rerun_attempts: args._rerun_attempt || 0
} : undefined,
safety_validation: args.safety_requirements?.enabled ? {
input_validated: true,
output_validated: true,
compliance_standards: args.safety_requirements.compliance_standards || [],
audit_logged: args.safety_requirements.audit_logging || false
} : undefined
}, null, 2),
},
],
};
}
/**
* Route to single agent
*/
async handleRouteToAgent(args) {
const { agent_name, tool_name, input, timeout_ms, retry_config } = args;
console.error(`[Router] Single agent call: ${agent_name}.${tool_name}`);
const node = {
id: 'single_call',
agent: agent_name,
tool: tool_name,
input,
dependencies: [],
timeout_ms,
retry_policy: retry_config,
};
const result = await routeToAgent(node, input);
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
/**
* Get execution status from memory
*/
async handleGetExecutionStatus(args) {
const { execution_id } = args;
console.error(`[Router] Getting status for: ${execution_id}`);
const state = await this.memoryClient.retrieveExecutionState(execution_id);
if (!state) {
throw new Error(`Execution not found: ${execution_id}`);
}
const total_nodes = state.graph?.nodes?.length || 0;
const progress_percentage = total_nodes > 0
? (state.completed_nodes.length / total_nodes) * 100
: 0;
return {
content: [
{
type: 'text',
text: JSON.stringify({
execution_id,
status: state.status,
progress_percentage,
nodes_completed: state.completed_nodes.length,
nodes_total: total_nodes,
nodes_failed: state.failed_nodes.length,
current_node: state.current_node,
created_at: state.created_at,
updated_at: state.updated_at,
waiting_for_approval: state.status === 'paused_for_approval' ? {
node_id: state.paused_at_node,
paused_at: state.paused_at,
} : undefined,
}, null, 2),
},
],
};
}
/**
* Pause execution
*/
async handlePauseExecution(args) {
const { execution_id, reason } = args;
const state = await this.memoryClient.retrieveExecutionState(execution_id);
if (!state) {
throw new Error(`Execution not found: ${execution_id}`);
}
if (state.status !== 'running') {
throw new Error(`Cannot pause execution in status: ${state.status}`);
}
state.status = 'paused_for_approval';
state.paused_at = new Date().toISOString();
state.paused_reason = reason;
await this.memoryClient.storeExecutionState(state);
return {
content: [
{
type: 'text',
text: JSON.stringify({
execution_id,
paused: true,
paused_at_node: state.current_node,
}, null, 2),
},
],
};
}
/**
* Resume execution
*/
async handleResumeExecution(args) {
const { execution_id, approval_decision } = args;
const state = await this.memoryClient.retrieveExecutionState(execution_id);
if (!state) {
throw new Error(`Execution not found: ${execution_id}`);
}
if (state.status !== 'paused_for_approval') {
throw new Error(`Cannot resume execution in status: ${state.status}`);
}
// TODO: Continue execution from paused node
state.status = 'running';
state.updated_at = new Date().toISOString();
await this.memoryClient.storeExecutionState(state);
return {
content: [
{
type: 'text',
text: JSON.stringify({
execution_id,
resumed: true,
message: 'Full resume logic needs implementation - this marks as resumed',
}, null, 2),
},
],
};
}
/**
* Cancel execution
*/
async handleCancelExecution(args) {
const { execution_id, reason } = args;
const state = await this.memoryClient.retrieveExecutionState(execution_id);
if (!state) {
throw new Error(`Execution not found: ${execution_id}`);
}
state.status = 'cancelled';
state.cancelled_at = new Date().toISOString();
state.cancelled_reason = reason;
await this.memoryClient.storeExecutionState(state);
return {
content: [
{
type: 'text',
text: JSON.stringify({
execution_id,
cancelled: true,
}, null, 2),
},
],
};
}
setupErrorHandling() {
this.server.onerror = (error) => {
console.error('[MCP Error]', error);
};
process.on('SIGINT', async () => {
await this.memoryClient.disconnect();
await this.server.close();
process.exit(0);
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Router MCP Server running on stdio');
console.error('โ
Memory-backed state management enabled');
console.error('โ
DAG validation and topological execution');
console.error('โ
Parallel execution support');
console.error('โ
HITL integration ready');
}
}
// Start the server
const server = new RouterServer();
server.run().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
//# sourceMappingURL=index.js.map