index.js•10.9 kB
// index.js (Simplified for Stdio Transport)
import path from 'path';
import { fileURLToPath } from 'url';
import { createClient } from 'redis';
import { z } from 'zod';
// MCP Server goodies
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import { parseRTDQFile, getNodeById } from './dt_mcp.js';
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ALLOWED_RTDQ_DIRECTORY = path.resolve(process.env.RTDQ_DIR || path.join(__dirname, 'rtdq_files'));
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const REDIS_KEY_PREFIX = 'rtdq:';
const TODO_LIST_KEY = 'mcp:todos';
// --- Zod Schemas (Keep these) ---
const LoadRtdqArgsSchema = z.object({ relativePath: z.string().min(1) });
const GetNodeArgsSchema = z.object({ rtdqIdentifier: z.string().min(1), taskName: z.string().min(1), nodeId: z.string().min(1) });
const AddTodoArgsSchema = z.object({ taskDescription: z.string().min(1), relatedRtdqId: z.string().optional() });
const ListTodosArgsSchema = z.object({});
const MarkTodoDoneArgsSchema = z.object({ taskId: z.string().min(1) });
// --- End Zod Schemas ---
const redisClient = createClient({ url: REDIS_URL });
redisClient.on('error', (err) => console.error('Redis Client Error:', err));
async function main() {
try {
await redisClient.connect();
} catch (err) {
console.error(`FATAL: Could not connect to Redis at ${REDIS_URL}`, err);
process.exit(1);
}
const mcpServer = new Server(
{ name: 'dt_mcp_server_full', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{ name: 'load_rtdq_to_redis', description: 'Load/reload .rtdq file from disk into Redis cache.', inputSchema: { type: 'object', properties: { relativePath: { type: 'string', description: "Relative path within allowed dir (e.g., 'planning/trip.rtdq')" } }, required: ['relativePath'] } },
{ name: 'get_dt_node_from_redis', description: 'Get node data from loaded .rtdq in Redis.', inputSchema: { type: 'object', properties: { rtdqIdentifier: { type: 'string' }, taskName: { type: 'string' }, nodeId: { type: 'string' } }, required: ['rtdqIdentifier', 'taskName', 'nodeId'] } },
{ name: 'add_todo', description: 'Add task to to-do list.', inputSchema: { type: 'object', properties: { taskDescription: { type: 'string' }, relatedRtdqId: { type: 'string' } }, required: ['taskDescription'] } },
{ name: 'list_todos', description: 'List active to-do items.', inputSchema: { type: 'object', properties: {} } },
{ name: 'mark_todo_done', description: 'Mark a todo item as completed by ID.', inputSchema: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] } },
],
};
});
// --- Implement Tool Handlers ---
mcpServer.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name: toolName, arguments: rawArgs } = req.params;
let args;
const createErrorResponse = (message, code = 'TOOL_EXECUTION_ERROR') => {
console.error(`Error in tool "${toolName}": ${message}`);
return { isError: true, content: [{ type: 'json', json: { error: code, message: message } }] };
};
try {
// Validate Input
switch (toolName) {
case 'load_rtdq_to_redis': args = LoadRtdqArgsSchema.parse(rawArgs); break;
case 'get_dt_node_from_redis': args = GetNodeArgsSchema.parse(rawArgs); break;
case 'add_todo': args = AddTodoArgsSchema.parse(rawArgs); break;
case 'list_todos': args = ListTodosArgsSchema.parse(rawArgs); break;
case 'mark_todo_done': args = MarkTodoDoneArgsSchema.parse(rawArgs); break;
default: throw new Error(`Tool not found: ${toolName}`);
}
// Execute Tool Logic
if (toolName === 'load_rtdq_to_redis') {
const { relativePath } = args;
const absolutePath = path.resolve(ALLOWED_RTDQ_DIRECTORY, relativePath);
if (!absolutePath.startsWith(ALLOWED_RTDQ_DIRECTORY) || !absolutePath.endsWith('.rtdq')) {
throw new Error('Access denied or invalid file type.');
}
// console.error(`Loading RTDQ: ${absolutePath}`);
const parsedData = await parseRTDQFile(absolutePath);
const redisKey = `${REDIS_KEY_PREFIX}${relativePath}`;
await redisClient.set(redisKey, JSON.stringify(parsedData));
// console.error(`Loaded RTDQ to Redis: ${parsedData.rtdq_name} -> ${redisKey}`);
return { content: [{ type: 'text', text: `Loaded .rtdq to cache: ${parsedData.rtdq_name} (ID: ${relativePath})` }] };
}
else if (toolName === 'get_dt_node_from_redis') {
const { rtdqIdentifier, taskName, nodeId } = args;
const redisKey = `${REDIS_KEY_PREFIX}${rtdqIdentifier}`;
const jsonData = await redisClient.get(redisKey);
if (!jsonData) throw new Error(`RTDQ data not found in cache for ID: ${rtdqIdentifier}`);
const rtdqData = JSON.parse(jsonData);
const node = getNodeById(rtdqData, taskName, nodeId);
if (!node) throw new Error(`Node ID "${nodeId}"/"${taskName}" not found for RTDQ "${rtdqIdentifier}".`);
return { content: [{ type: 'json', json: { nodeId: node.id, prompt: node.prompt, children: node.children || [] } }] };
}
else if (toolName === 'add_todo') {
const { taskDescription, relatedRtdqId } = args;
const taskId = `task_${Date.now()}`;
const todoItem = JSON.stringify({ id: taskId, description: taskDescription, relatedRtdqId: relatedRtdqId || null, done: false });
await redisClient.lPush(TODO_LIST_KEY, todoItem);
return { content: [{ type: 'text', text: `Added todo: "${taskDescription}" (ID: ${taskId})` }] };
}
else if (toolName === 'list_todos') {
const todoStrings = await redisClient.lRange(TODO_LIST_KEY, 0, -1);
const todos = todoStrings.map(s => JSON.parse(s)).filter(t => !t.done);
if (todos.length === 0) return { content: [{ type: 'text', text: "No active to-do items." }] };
const todoListText = todos.map(t => `- ${t.description} (ID: ${t.id})`).join('\n');
return { content: [{ type: 'text', text: `Active To-Dos:\n${todoListText}` }] };
}
else if (toolName === 'mark_todo_done') {
const { taskId } = args;
const todoStrings = await redisClient.lRange(TODO_LIST_KEY, 0, -1);
let updated = false; const updatedTodos = [];
for (const s of todoStrings) {
let todo = JSON.parse(s);
if (todo.id === taskId && !todo.done) { todo.done = true; updated = true; }
updatedTodos.push(JSON.stringify(todo));
}
if (!updated) throw new Error(`Could not find active todo with ID: ${taskId}`);
await redisClient.del(TODO_LIST_KEY);
if (updatedTodos.length > 0) await redisClient.lPush(TODO_LIST_KEY, updatedTodos);
return { content: [{ type: 'text', text: `Marked todo ID ${taskId} as done.` }] };
}
return createErrorResponse(`Handler logic missing for tool: ${toolName}`, 'NOT_IMPLEMENTED');
} catch (error) {
let errorCode = 'TOOL_EXECUTION_ERROR';
if (error instanceof z.ZodError) { errorCode = 'INVALID_INPUT'; return createErrorResponse(`Invalid input: ${error.errors.map(e=>`${e.path.join('.')}: ${e.message}`).join(', ')}`, errorCode); }
else if (error.message.includes('Tool not found')) { errorCode = 'TOOL_NOT_FOUND'; }
else if (error.message.includes('not found in cache')) { errorCode = 'DATA_NOT_FOUND'; }
else if (error.message.includes('Node ID') && error.message.includes('not found')) { errorCode = 'NODE_NOT_FOUND'; }
else if (error.message.includes('active todo') && error.message.includes('not find')) { errorCode = 'TODO_NOT_FOUND'; }
else if (error instanceof SyntaxError) { errorCode = 'DATA_CORRUPTION'; error.message = `Failed to parse stored data: ${error.message}`; }
else if (error.code === 'ENOENT') { errorCode = 'FILE_NOT_FOUND'; error.message = `Required file not found: ${error.message}`; }
else if (error.message.includes('Access denied')) { errorCode = 'ACCESS_DENIED'; }
return createErrorResponse(error.message || 'An unexpected error occurred.', errorCode);
}
});
// --- Connect Stdio Transport ---
// Remove app.get('/mcp', ...) and app.listen(...)
// Explicitly create and connect the StdioServerTransport
const stdioTransport = new StdioServerTransport();
await mcpServer.connect(stdioTransport);
console.error("MCP Server connected via Stdio. Awaiting client initialization."); // Log readiness to stderr
// --- Keep Process Alive (or handle shutdown) ---
// The process needs to stay running to listen on stdio.
// The graceful shutdown handler remains important.
const shutdown = async (signal) => {
console.error(`Received ${signal}. Shutting down...`);
try {
// Optional: Add any specific cleanup for MCP server/transport if needed
// stdioTransport.close(); // If transport has a close method
if (redisClient.isOpen) { await redisClient.quit(); }
} catch (err) { console.error("Error during shutdown:", err); }
finally { process.exit(0); }
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
// Keep Node running until signal
// This basic approach keeps the process alive; more robust methods exist
await new Promise(() => {}); // Keep running indefinitely until shutdown signal
}
main().catch(async (err) => {
console.error("Fatal error during server startup:", err);
if (redisClient?.isOpen) {
try { await redisClient.quit(); } catch (e) { console.error("Error quitting Redis during fatal startup error:", e); }
}
process.exit(1);
});