UNPKG

frappe-mcp-server

Version:

Enhanced Model Context Protocol server for Frappe Framework with comprehensive API instructions and helper tools

503 lines (443 loc) 14.3 kB
#!/usr/bin/env node /** * Streamable HTTP-based Frappe MCP Server * Implements the modern MCP Streamable HTTP transport with optional SSE streaming */ import express from 'express'; import cors from 'cors'; import { z } from 'zod'; import { randomUUID } from 'crypto'; import { initializeStaticHints } from './static-hints.js'; import { initializeAppIntrospection } from './app-introspection.js'; import { validateApiCredentials } from './auth.js'; // Import all tool functions import { findDocTypes, getModuleList, getDocTypesInModule, doesDocTypeExist, doesDocumentExist, getDocumentCount, getNamingSeriesInfo, getRequiredFields } from './frappe-helpers.js'; import { getInstructions } from './frappe-instructions.js'; import { createDocument, getDocument, updateDocument, deleteDocument, listDocuments, callMethod } from './frappe-api.js'; import { getDocTypeSchema, getFieldOptions } from './frappe-api.js'; import { getDocTypeHints, getWorkflowHints, findWorkflowsForDocType } from './static-hints.js'; import { getDocTypeUsageInstructions, getAppForDocType, getAppUsageInstructions } from './app-introspection.js'; const app = express(); const port = process.env.PORT || 0xCAF1; // Port 51953 = 0xCAF1 (CAFE+1) in hex // Session management interface Session { id: string; clientId?: string; createdAt: Date; lastActivity: Date; messageQueue: any[]; } const sessions = new Map<string, Session>(); const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes // Middleware app.use(cors({ origin: process.env.NODE_ENV === 'production' ? 'https://claude.ai' : '*', credentials: true, methods: ['POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'x-session-id'] })); app.use(express.json({ limit: '4mb' })); // Session middleware app.use((req, res, next) => { const sessionId = req.headers['x-session-id'] as string; if (sessionId) { const session = sessions.get(sessionId); if (session) { session.lastActivity = new Date(); req.session = session; } } next(); }); // Clean up expired sessions setInterval(() => { const now = Date.now(); for (const [id, session] of sessions) { if (now - session.lastActivity.getTime() > SESSION_TIMEOUT) { sessions.delete(id); console.log(`Session ${id} expired and removed`); } } }, 60000); // Check every minute // Tool definitions (same as http-server.ts) const tools = { ping: { description: "A simple tool to check if the server is responding.", schema: z.object({}), handler: async () => ({ content: [{ type: "text", text: "pong" }] }) }, find_doctypes: { description: "Find DocTypes in the system matching a search term", schema: z.object({ search_term: z.string().optional(), module: z.string().optional(), is_table: z.boolean().optional(), is_single: z.boolean().optional(), is_custom: z.boolean().optional(), limit: z.number().optional() }), handler: async (params: any) => { const result = await findDocTypes(params.search_term, { module: params.module, isTable: params.is_table, isSingle: params.is_single, isCustom: params.is_custom, limit: params.limit }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } }, get_module_list: { description: "Get a list of all modules in the system", schema: z.object({}), handler: async () => { const result = await getModuleList(); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } }, check_doctype_exists: { description: "Check if a DocType exists in the system", schema: z.object({ doctype: z.string() }), handler: async (params: any) => { const exists = await doesDocTypeExist(params.doctype); return { content: [{ type: "text", text: JSON.stringify({ exists }, null, 2) }] }; } }, get_doctype_schema: { description: "Get the complete schema for a DocType", schema: z.object({ doctype: z.string() }), handler: async (params: any) => { const result = await getDocTypeSchema(params.doctype); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } }, list_documents: { description: "List documents from Frappe with filters", schema: z.object({ doctype: z.string(), filters: z.record(z.any()).optional(), fields: z.array(z.string()).optional(), limit: z.number().optional(), order_by: z.string().optional(), limit_start: z.number().optional() }), handler: async (params: any) => { const result = await listDocuments( params.doctype, params.filters, params.fields, params.limit, params.order_by, params.limit_start ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } } }; // Helper to determine if a method should stream function shouldStream(method: string): boolean { // Methods that might benefit from streaming const streamableMethods = [ 'tools/call', // Long-running tools 'resources/read', // Large resource reads 'prompts/run', // Interactive prompts ]; return streamableMethods.some(m => method?.startsWith(m)); } // Helper to send SSE message function sendSSE(res: express.Response, data: any) { const message = `data: ${JSON.stringify(data)}\n\n`; res.write(message); } // Main MCP endpoint - Streamable HTTP app.post('/', async (req, res) => { try { const { jsonrpc, method, params = {}, id } = req.body; // Validate JSON-RPC format if (jsonrpc !== "2.0") { return res.status(400).json({ jsonrpc: "2.0", id: id || null, error: { code: -32600, message: "Invalid Request - must use JSON-RPC 2.0" } }); } // Session handling for stateful operations let session = req.session; if (!session && method === 'initialize') { // Create new session session = { id: randomUUID(), createdAt: new Date(), lastActivity: new Date(), messageQueue: [] }; sessions.set(session.id, session); res.setHeader('x-session-id', session.id); } // Determine if we should stream the response const streaming = shouldStream(method) && req.headers.accept?.includes('text/event-stream'); if (streaming) { // Set up SSE headers res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', // Disable nginx buffering 'x-session-id': session?.id || '' }); // Send initial response sendSSE(res, { jsonrpc: "2.0", id, result: { streaming: true, sessionId: session?.id } }); // Keep connection alive const heartbeat = setInterval(() => { res.write(':heartbeat\n\n'); }, 30000); // Clean up on disconnect req.on('close', () => { clearInterval(heartbeat); console.log(`SSE connection closed for session ${session?.id}`); }); } // Handle MCP protocol methods switch (method) { case 'initialize': { const result = { protocolVersion: "2024-11-05", capabilities: { tools: {}, resources: { subscribe: false, listChanged: false }, prompts: {}, logging: {} }, serverInfo: { name: "frappe-mcp-server", version: "0.2.16" }, sessionId: session?.id }; if (streaming) { sendSSE(res, { jsonrpc: "2.0", id, result }); res.end(); } else { res.json({ jsonrpc: "2.0", id, result }); } break; } case 'tools/list': { const toolList = Object.entries(tools).map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.schema.shape ? tool.schema._def.shape() : {} })); const result = { tools: toolList }; if (streaming) { sendSSE(res, { jsonrpc: "2.0", id, result }); res.end(); } else { res.json({ jsonrpc: "2.0", id, result }); } break; } case 'tools/call': { const { name: toolName, arguments: toolArgs = {} } = params; if (!toolName || !tools[toolName as keyof typeof tools]) { const error = { code: -32601, message: `Tool '${toolName}' not found`, data: { availableTools: Object.keys(tools) } }; if (streaming) { sendSSE(res, { jsonrpc: "2.0", id, error }); res.end(); } else { res.status(404).json({ jsonrpc: "2.0", id, error }); } return; } const toolDef = tools[toolName as keyof typeof tools]; try { const validatedArgs = toolDef.schema.parse(toolArgs); if (streaming) { // Send progress updates for long-running operations sendSSE(res, { jsonrpc: "2.0", method: "tools/call/progress", params: { tool: toolName, status: "started" } }); } const result = await toolDef.handler(validatedArgs); if (streaming) { sendSSE(res, { jsonrpc: "2.0", id, result }); res.end(); } else { res.json({ jsonrpc: "2.0", id, result }); } } catch (error) { const errorResponse = { jsonrpc: "2.0", id, error: { code: error instanceof z.ZodError ? -32602 : -32603, message: error instanceof Error ? error.message : 'Internal error', data: error instanceof z.ZodError ? error.errors : undefined } }; if (streaming) { sendSSE(res, errorResponse); res.end(); } else { res.status(500).json(errorResponse); } } break; } case 'notifications/message': { // Handle notifications (no response expected) if (id) { res.status(400).json({ jsonrpc: "2.0", id, error: { code: -32600, message: "Notifications should not include an id" } }); } else { res.status(204).end(); // No content for notifications } break; } default: { const error = { code: -32601, message: `Method '${method}' not found` }; if (streaming) { sendSSE(res, { jsonrpc: "2.0", id, error }); res.end(); } else { res.status(404).json({ jsonrpc: "2.0", id, error }); } } } } catch (error) { console.error('Error in Streamable HTTP handler:', error); const errorResponse = { jsonrpc: "2.0", id: req.body.id || null, error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' } }; if (res.headersSent) { // If we're streaming and headers are sent, send error via SSE res.write(`data: ${JSON.stringify(errorResponse)}\n\n`); res.end(); } else { res.status(500).json(errorResponse); } } }); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'healthy', server: 'frappe-mcp-server', version: '0.2.16', transport: 'streamable-http', sessions: sessions.size }); }); // Server info endpoint app.get('/info', (req, res) => { const toolNames = Object.keys(tools); res.json({ name: "frappe-mcp-server", version: "0.2.16", transport: "streamable-http", protocol: "2024-11-05", capabilities: { streaming: true, stateful: true }, tools: toolNames.length, availableTools: toolNames, endpoints: { mcp: '/', health: '/health', info: '/info' } }); }); async function startServer() { try { console.log("Starting Frappe MCP Streamable HTTP server..."); // Validate credentials await validateApiCredentials(); console.log("API credentials validation successful."); // Initialize components console.log("Initializing static hints..."); await initializeStaticHints(); console.log("Static hints initialized successfully"); console.log("Initializing app introspection..."); await initializeAppIntrospection(); console.log("App introspection initialized successfully"); // Start server app.listen(port, () => { console.log(`🚀 Frappe MCP server running with Streamable HTTP at http://localhost:${port}`); console.log(`☕ Port ${port} = 0xCAF1 in hexadecimal. The next evolution of Frappe Café!`); console.log(`📋 Endpoints:`); console.log(` POST / - MCP Streamable HTTP endpoint (JSON-RPC 2.0)`); console.log(` GET /health - Health check`); console.log(` GET /info - Server information`); console.log(`\n✨ Features:`); console.log(` - Streamable HTTP transport with optional SSE`); console.log(` - Stateful sessions with automatic cleanup`); console.log(` - JSON and SSE response formats`); console.log(` - Full MCP protocol compliance`); }); } catch (error) { console.error("Failed to start server:", error); process.exit(1); } } // Handle graceful shutdown process.on('SIGINT', () => { console.log('\nShutting down Frappe MCP Streamable HTTP server...'); sessions.clear(); process.exit(0); }); process.on('SIGTERM', () => { console.log('\nShutting down Frappe MCP Streamable HTTP server...'); sessions.clear(); process.exit(0); }); // Add type declaration for session declare global { namespace Express { interface Request { session?: Session; } } } startServer();