edoardo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,697 @@
1
+ /**
2
+ * Standalone Server for Edoardo
3
+ *
4
+ * This server combines:
5
+ * - Static file serving for the frontend (from dist/)
6
+ * - MCP proxy endpoints for plugin communication
7
+ * - Serverless-like API routes for plugins
8
+ *
9
+ * Used when running as an npm package (edoardo command)
10
+ */
11
+
12
+ import express from 'express';
13
+ import cors from 'cors';
14
+ import { spawn } from 'child_process';
15
+ import { fileURLToPath } from 'url';
16
+ import { dirname, join } from 'path';
17
+ import { existsSync, readFileSync } from 'fs';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+ const rootDir = join(__dirname, '..');
22
+ const distDir = join(rootDir, 'dist');
23
+
24
+ const app = express();
25
+ const PORT = process.env.PORT || 3001;
26
+
27
+ // Store for active stdio MCP server processes
28
+ const stdioServers = new Map();
29
+
30
+ // ============ STATIC FILE SERVING (must be first!) ============
31
+
32
+ // Serve index.html for root path
33
+ app.get('/', (req, res) => {
34
+ const indexPath = join(distDir, 'index.html');
35
+ if (existsSync(indexPath)) {
36
+ const html = readFileSync(indexPath, 'utf-8');
37
+ res.type('html').send(html);
38
+ } else {
39
+ res.status(404).send('Application not found. Please run "npm run build" first.');
40
+ }
41
+ });
42
+
43
+ // Serve static files from dist directory (assets, etc.)
44
+ app.use(express.static(distDir));
45
+
46
+ // Enable CORS (after static files to avoid CORS issues with assets)
47
+ app.use(cors({
48
+ origin: true,
49
+ methods: ['GET', 'POST', 'OPTIONS'],
50
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-MCP-Endpoint', 'X-MCP-Auth-Type', 'X-MCP-Auth-Token', 'X-MCP-Type', 'X-MCP-Command', 'X-MCP-Args', 'X-MCP-Allowed-Paths'],
51
+ }));
52
+
53
+ app.use(express.json());
54
+
55
+ // ============ HEALTH CHECK ============
56
+
57
+ app.get('/health', (req, res) => {
58
+ res.json({ status: 'ok', mode: 'standalone', activeStdioServers: stdioServers.size });
59
+ });
60
+
61
+ // ============ MCP PROXY HELPERS ============
62
+
63
+ async function parseSSEResponse(response) {
64
+ const text = await response.text();
65
+ const lines = text.split('\n');
66
+ let result = null;
67
+ let dataBuffer = '';
68
+
69
+ for (const line of lines) {
70
+ if (line.startsWith('data:')) {
71
+ const dataStr = line.slice(5).trim();
72
+ if (dataStr && dataStr !== '[DONE]') {
73
+ dataBuffer += dataStr;
74
+ }
75
+ continue;
76
+ }
77
+ if (line.trim() === '' && dataBuffer) {
78
+ try {
79
+ const parsed = JSON.parse(dataBuffer);
80
+ if (parsed.result !== undefined || parsed.error !== undefined || parsed.id !== undefined) {
81
+ result = parsed;
82
+ }
83
+ } catch (e) {}
84
+ dataBuffer = '';
85
+ }
86
+ }
87
+
88
+ if (dataBuffer) {
89
+ try {
90
+ const parsed = JSON.parse(dataBuffer);
91
+ if (parsed.result !== undefined || parsed.error !== undefined || parsed.id !== undefined) {
92
+ result = parsed;
93
+ }
94
+ } catch (e) {}
95
+ }
96
+
97
+ return result;
98
+ }
99
+
100
+ async function handleMcpResponse(response) {
101
+ const contentType = response.headers.get('content-type') || '';
102
+ if (contentType.includes('text/event-stream')) {
103
+ return await parseSSEResponse(response);
104
+ } else {
105
+ return await response.json();
106
+ }
107
+ }
108
+
109
+ async function initHttpMcpSession(endpoint, headers) {
110
+ try {
111
+ const initRes = await fetch(endpoint, {
112
+ method: 'POST',
113
+ headers,
114
+ body: JSON.stringify({
115
+ jsonrpc: '2.0',
116
+ id: 0,
117
+ method: 'initialize',
118
+ params: {
119
+ protocolVersion: '2024-11-05',
120
+ capabilities: {},
121
+ clientInfo: { name: 'edoardo', version: '1.0.0' }
122
+ }
123
+ })
124
+ });
125
+
126
+ const sessionId = initRes.headers.get('mcp-session-id');
127
+
128
+ if (initRes.ok) {
129
+ await handleMcpResponse(initRes);
130
+ } else {
131
+ return null;
132
+ }
133
+
134
+ const notifHeaders = { ...headers };
135
+ if (sessionId) notifHeaders['Mcp-Session-Id'] = sessionId;
136
+
137
+ await fetch(endpoint, {
138
+ method: 'POST',
139
+ headers: notifHeaders,
140
+ body: JSON.stringify({
141
+ jsonrpc: '2.0',
142
+ method: 'notifications/initialized'
143
+ })
144
+ }).then(res => res.text()).catch(() => {});
145
+
146
+ return sessionId;
147
+ } catch (err) {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ // ============ STDIO MCP SERVER CLASS ============
153
+
154
+ class StdioMcpServer {
155
+ constructor(command, args, env = {}) {
156
+ this.command = command;
157
+ this.args = args;
158
+ this.env = env;
159
+ this.process = null;
160
+ this.messageId = 0;
161
+ this.pendingRequests = new Map();
162
+ this.buffer = '';
163
+ this.initialized = false;
164
+ }
165
+
166
+ async start() {
167
+ return new Promise((resolve, reject) => {
168
+ const enhancedEnv = {
169
+ ...process.env,
170
+ ...this.env,
171
+ PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`
172
+ };
173
+
174
+ this.process = spawn(this.command, this.args, {
175
+ stdio: ['pipe', 'pipe', 'pipe'],
176
+ shell: true,
177
+ env: enhancedEnv,
178
+ });
179
+
180
+ this.process.stdout.on('data', (data) => {
181
+ this.handleData(data.toString());
182
+ });
183
+
184
+ this.process.stderr.on('data', (data) => {
185
+ const output = data.toString().trim();
186
+ if (output.includes('version') || output.includes('initialized') || output.includes('Starting')) {
187
+ console.log(`[MCP] ${this.command}: ${output.substring(0, 200)}`);
188
+ }
189
+ });
190
+
191
+ this.process.on('error', (err) => {
192
+ reject(err);
193
+ });
194
+
195
+ let startupRejected = false;
196
+ this.process.on('exit', (code, signal) => {
197
+ if (!this.initialized && !startupRejected) {
198
+ startupRejected = true;
199
+ reject(new Error(`Server exited during startup (code: ${code})`));
200
+ }
201
+ });
202
+
203
+ this.process.on('close', (code) => {
204
+ for (const { reject } of this.pendingRequests.values()) {
205
+ reject(new Error(`Server process exited with code ${code}`));
206
+ }
207
+ this.cleanup();
208
+ });
209
+
210
+ setTimeout(async () => {
211
+ try {
212
+ await this.initialize();
213
+ resolve();
214
+ } catch (err) {
215
+ reject(err);
216
+ }
217
+ }, 1500);
218
+ });
219
+ }
220
+
221
+ async initialize() {
222
+ const initResult = await this.sendRequest('initialize', {
223
+ protocolVersion: '2024-11-05',
224
+ capabilities: {},
225
+ clientInfo: { name: 'edoardo', version: '1.0.0' }
226
+ });
227
+ this.sendNotification('notifications/initialized', {});
228
+ this.initialized = true;
229
+ return initResult;
230
+ }
231
+
232
+ handleData(data) {
233
+ this.buffer += data;
234
+ const lines = this.buffer.split('\n');
235
+ this.buffer = lines.pop() || '';
236
+
237
+ for (const line of lines) {
238
+ if (line.trim()) {
239
+ try {
240
+ const message = JSON.parse(line);
241
+ this.handleMessage(message);
242
+ } catch (e) {}
243
+ }
244
+ }
245
+ }
246
+
247
+ handleMessage(message) {
248
+ if (message.id !== undefined && this.pendingRequests.has(message.id)) {
249
+ const { resolve, reject } = this.pendingRequests.get(message.id);
250
+ this.pendingRequests.delete(message.id);
251
+ if (message.error) {
252
+ reject(new Error(message.error.message || 'Unknown error'));
253
+ } else {
254
+ resolve(message.result);
255
+ }
256
+ }
257
+ }
258
+
259
+ sendRequest(method, params) {
260
+ return new Promise((resolve, reject) => {
261
+ const id = ++this.messageId;
262
+ const request = { jsonrpc: '2.0', id, method, params };
263
+
264
+ const timeoutId = setTimeout(() => {
265
+ if (this.pendingRequests.has(id)) {
266
+ this.pendingRequests.delete(id);
267
+ reject(new Error(`Request timeout (method: ${method})`));
268
+ }
269
+ }, 30000);
270
+
271
+ this.pendingRequests.set(id, {
272
+ resolve: (result) => { clearTimeout(timeoutId); resolve(result); },
273
+ reject: (err) => { clearTimeout(timeoutId); reject(err); }
274
+ });
275
+
276
+ try {
277
+ this.process.stdin.write(JSON.stringify(request) + '\n');
278
+ } catch (err) {
279
+ clearTimeout(timeoutId);
280
+ this.pendingRequests.delete(id);
281
+ reject(new Error(`Failed to send request: ${err.message}`));
282
+ }
283
+ });
284
+ }
285
+
286
+ sendNotification(method, params) {
287
+ const notification = { jsonrpc: '2.0', method, params };
288
+ this.process.stdin.write(JSON.stringify(notification) + '\n');
289
+ }
290
+
291
+ async listTools() {
292
+ return await this.sendRequest('tools/list', {});
293
+ }
294
+
295
+ async callTool(name, args) {
296
+ return await this.sendRequest('tools/call', { name, arguments: args });
297
+ }
298
+
299
+ cleanup() {
300
+ this.initialized = false;
301
+ this.pendingRequests.clear();
302
+ }
303
+
304
+ stop() {
305
+ if (this.process) {
306
+ this.process.kill();
307
+ this.process = null;
308
+ }
309
+ this.cleanup();
310
+ }
311
+ }
312
+
313
+ async function getOrCreateStdioServer(serverId, command, args, env = {}) {
314
+ if (stdioServers.has(serverId)) {
315
+ const server = stdioServers.get(serverId);
316
+ if (server.process && !server.process.killed) {
317
+ return server;
318
+ }
319
+ stdioServers.delete(serverId);
320
+ }
321
+
322
+ const server = new StdioMcpServer(command, args, env);
323
+ await server.start();
324
+ stdioServers.set(serverId, server);
325
+ return server;
326
+ }
327
+
328
+ // ============ MCP STDIO ENDPOINTS ============
329
+
330
+ app.post('/mcp/stdio/start', async (req, res) => {
331
+ const { serverId, command, args, env } = req.body;
332
+ if (!serverId || !command) {
333
+ return res.status(400).json({ error: 'Missing serverId or command' });
334
+ }
335
+ try {
336
+ const server = await getOrCreateStdioServer(serverId, command, args || [], env || {});
337
+ res.json({ status: 'ok', initialized: server.initialized });
338
+ } catch (error) {
339
+ res.status(500).json({ error: 'Failed to start server', message: error.message });
340
+ }
341
+ });
342
+
343
+ app.post('/mcp/stdio/tools/list', async (req, res) => {
344
+ const { serverId, command, args, env } = req.body;
345
+ if (!serverId || !command) {
346
+ return res.status(400).json({ error: 'Missing serverId or command' });
347
+ }
348
+ try {
349
+ const server = await getOrCreateStdioServer(serverId, command, args || [], env || {});
350
+ const result = await server.listTools();
351
+ res.json(result);
352
+ } catch (error) {
353
+ res.status(500).json({ error: 'Failed to list tools', message: error.message });
354
+ }
355
+ });
356
+
357
+ app.post('/mcp/stdio/tools/call', async (req, res) => {
358
+ const { serverId, command, args, env, toolName, toolArgs } = req.body;
359
+ if (!serverId || !command || !toolName) {
360
+ return res.status(400).json({ error: 'Missing required parameters' });
361
+ }
362
+ try {
363
+ const server = await getOrCreateStdioServer(serverId, command, args || [], env || {});
364
+ const result = await server.callTool(toolName, toolArgs || {});
365
+ res.json(result);
366
+ } catch (error) {
367
+ res.status(500).json({ error: 'Failed to call tool', message: error.message });
368
+ }
369
+ });
370
+
371
+ app.post('/mcp/stdio/stop', async (req, res) => {
372
+ const { serverId } = req.body;
373
+ if (!serverId) {
374
+ return res.status(400).json({ error: 'Missing serverId' });
375
+ }
376
+ if (stdioServers.has(serverId)) {
377
+ const server = stdioServers.get(serverId);
378
+ server.stop();
379
+ stdioServers.delete(serverId);
380
+ res.json({ status: 'stopped' });
381
+ } else {
382
+ res.json({ status: 'not_found' });
383
+ }
384
+ });
385
+
386
+ // ============ MCP HTTP PROXY ENDPOINTS ============
387
+
388
+ app.post('/mcp/tools/list', async (req, res) => {
389
+ const endpoint = req.headers['x-mcp-endpoint'];
390
+ const authType = req.headers['x-mcp-auth-type'];
391
+ const authToken = req.headers['x-mcp-auth-token'];
392
+
393
+ if (!endpoint) {
394
+ return res.status(400).json({ error: 'Missing X-MCP-Endpoint header' });
395
+ }
396
+
397
+ try {
398
+ const headers = {
399
+ 'Content-Type': 'application/json',
400
+ 'Accept': 'application/json, text/event-stream',
401
+ };
402
+
403
+ const isLocalhost = endpoint.includes('localhost') || endpoint.includes('127.0.0.1');
404
+ if (authToken && !isLocalhost && (authType === 'bearer' || authType === 'oauth' || authType === 'api_key')) {
405
+ headers['Authorization'] = `Bearer ${authToken}`;
406
+ }
407
+
408
+ if (endpoint.includes('notion.com')) {
409
+ headers['Notion-Version'] = '2022-06-28';
410
+ }
411
+
412
+ if (!isLocalhost) {
413
+ const sessionId = await initHttpMcpSession(endpoint, headers);
414
+ if (sessionId) headers['Mcp-Session-Id'] = sessionId;
415
+ }
416
+
417
+ const mcpUrl = isLocalhost ? `${endpoint}/tools/list` : endpoint;
418
+ const response = await fetch(mcpUrl, {
419
+ method: 'POST',
420
+ headers,
421
+ body: JSON.stringify(req.body),
422
+ });
423
+
424
+ const data = await handleMcpResponse(response);
425
+ if (!data) {
426
+ return res.status(500).json({ error: 'Failed to parse MCP response' });
427
+ }
428
+ res.status(response.status).json(data);
429
+ } catch (error) {
430
+ res.status(500).json({ error: 'Proxy request failed', message: error.message });
431
+ }
432
+ });
433
+
434
+ app.post('/mcp/tools/call', async (req, res) => {
435
+ const endpoint = req.headers['x-mcp-endpoint'];
436
+ const authType = req.headers['x-mcp-auth-type'];
437
+ const authToken = req.headers['x-mcp-auth-token'];
438
+
439
+ if (!endpoint) {
440
+ return res.status(400).json({ error: 'Missing X-MCP-Endpoint header' });
441
+ }
442
+
443
+ try {
444
+ const headers = {
445
+ 'Content-Type': 'application/json',
446
+ 'Accept': 'application/json, text/event-stream',
447
+ };
448
+
449
+ if (authToken && (authType === 'bearer' || authType === 'oauth' || authType === 'api_key')) {
450
+ headers['Authorization'] = `Bearer ${authToken}`;
451
+ }
452
+
453
+ if (endpoint.includes('notion.com')) {
454
+ headers['Notion-Version'] = '2022-06-28';
455
+ }
456
+
457
+ const isLocalhost = endpoint.includes('localhost') || endpoint.includes('127.0.0.1');
458
+ if (!isLocalhost) {
459
+ const sessionId = await initHttpMcpSession(endpoint, headers);
460
+ if (sessionId) headers['Mcp-Session-Id'] = sessionId;
461
+ }
462
+
463
+ const mcpUrl = isLocalhost ? `${endpoint}/tools/call` : endpoint;
464
+ const response = await fetch(mcpUrl, {
465
+ method: 'POST',
466
+ headers,
467
+ body: JSON.stringify(req.body),
468
+ });
469
+
470
+ const data = await handleMcpResponse(response);
471
+ if (!data) {
472
+ return res.status(500).json({ error: 'Failed to parse MCP response' });
473
+ }
474
+ res.status(response.status).json(data);
475
+ } catch (error) {
476
+ res.status(500).json({ error: 'Proxy request failed', message: error.message });
477
+ }
478
+ });
479
+
480
+ // ============ N8N PLUGIN ROUTES ============
481
+
482
+ const N8N_TOOLS = [
483
+ { name: "list_workflows", description: "List all workflows in the n8n instance", inputSchema: { type: "object", properties: { active: { type: "boolean", description: "Filter by active status" }, limit: { type: "integer", description: "Maximum number of workflows to return" } }, required: [] } },
484
+ { name: "get_workflow", description: "Get details of a specific workflow by ID", inputSchema: { type: "object", properties: { workflow_id: { type: "string", description: "The ID of the workflow" } }, required: ["workflow_id"] } },
485
+ { name: "create_workflow", description: "Create a new workflow", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name of the workflow" }, nodes: { type: "array", description: "Array of node configurations", items: { type: "object", properties: {} } }, connections: { type: "object", description: "Connection mappings between nodes", properties: {} }, settings: { type: "object", description: "Workflow settings", properties: {} } }, required: ["name"] } },
486
+ { name: "update_workflow", description: "Update an existing workflow", inputSchema: { type: "object", properties: { workflow_id: { type: "string", description: "The ID of the workflow" }, name: { type: "string", description: "New name for the workflow" }, nodes: { type: "array", description: "Updated nodes", items: { type: "object", properties: {} } }, connections: { type: "object", description: "Updated connections", properties: {} }, settings: { type: "object", description: "Updated settings", properties: {} } }, required: ["workflow_id"] } },
487
+ { name: "delete_workflow", description: "Delete a workflow", inputSchema: { type: "object", properties: { workflow_id: { type: "string", description: "The ID of the workflow to delete" } }, required: ["workflow_id"] } },
488
+ { name: "activate_workflow", description: "Activate a workflow for automatic execution", inputSchema: { type: "object", properties: { workflow_id: { type: "string", description: "The ID of the workflow to activate" } }, required: ["workflow_id"] } },
489
+ { name: "deactivate_workflow", description: "Deactivate a workflow", inputSchema: { type: "object", properties: { workflow_id: { type: "string", description: "The ID of the workflow to deactivate" } }, required: ["workflow_id"] } },
490
+ { name: "execute_workflow", description: "Manually execute a workflow", inputSchema: { type: "object", properties: { workflow_id: { type: "string", description: "The ID of the workflow to execute" }, data: { type: "object", description: "Input data to pass to the workflow", properties: {} } }, required: ["workflow_id"] } },
491
+ { name: "list_executions", description: "List workflow executions", inputSchema: { type: "object", properties: { workflow_id: { type: "string", description: "Filter by workflow ID" }, status: { type: "string", enum: ["success", "error", "waiting"], description: "Filter by status" }, limit: { type: "integer", description: "Maximum number of executions to return" } }, required: [] } },
492
+ { name: "get_execution", description: "Get details of a specific execution", inputSchema: { type: "object", properties: { execution_id: { type: "string", description: "The ID of the execution" } }, required: ["execution_id"] } },
493
+ { name: "delete_execution", description: "Delete an execution record", inputSchema: { type: "object", properties: { execution_id: { type: "string", description: "The ID of the execution to delete" } }, required: ["execution_id"] } },
494
+ { name: "list_tags", description: "List all tags for organizing workflows", inputSchema: { type: "object", properties: {}, required: [] } },
495
+ { name: "create_tag", description: "Create a new tag", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name of the tag" } }, required: ["name"] } }
496
+ ];
497
+
498
+ async function handleN8nTool(toolName, toolArgs, env) {
499
+ const host = env?.N8N_HOST;
500
+ const apiKey = env?.N8N_API_KEY;
501
+ if (!host || !apiKey) throw new Error('Missing n8n credentials');
502
+
503
+ let baseUrl = host.replace(/\/$/, '');
504
+ if (!baseUrl.includes('/api/')) {
505
+ baseUrl = `${baseUrl}/api/v1`;
506
+ }
507
+
508
+ const n8nRequest = async (method, path, body = null) => {
509
+ const fullUrl = `${baseUrl}${path}`;
510
+ const options = {
511
+ method,
512
+ headers: {
513
+ 'X-N8N-API-KEY': apiKey,
514
+ 'Content-Type': 'application/json',
515
+ 'Accept': 'application/json'
516
+ }
517
+ };
518
+ if (body) options.body = JSON.stringify(body);
519
+
520
+ const response = await fetch(fullUrl, options);
521
+ if (!response.ok) {
522
+ const errText = await response.text();
523
+ let errData = {};
524
+ try { errData = JSON.parse(errText); } catch {}
525
+ throw new Error(`n8n API error (${response.status}): ${errData.message || errText}`);
526
+ }
527
+ return response.json();
528
+ };
529
+
530
+ switch (toolName) {
531
+ case 'list_workflows': {
532
+ const params = new URLSearchParams();
533
+ if (toolArgs.active !== undefined) params.set('active', String(toolArgs.active));
534
+ if (toolArgs.limit) params.set('limit', String(toolArgs.limit));
535
+ const queryString = params.toString() ? `?${params.toString()}` : '';
536
+ const result = await n8nRequest('GET', `/workflows${queryString}`);
537
+ return { workflows: result.data?.map(w => ({ id: w.id, name: w.name, active: w.active })) || result };
538
+ }
539
+ case 'get_workflow': {
540
+ const result = await n8nRequest('GET', `/workflows/${toolArgs.workflow_id}`);
541
+ return { id: result.id, name: result.name, active: result.active, nodes: result.nodes?.length || 0 };
542
+ }
543
+ case 'create_workflow': {
544
+ const body = { name: toolArgs.name, nodes: toolArgs.nodes || [], connections: toolArgs.connections || {}, settings: toolArgs.settings || {} };
545
+ const result = await n8nRequest('POST', '/workflows', body);
546
+ return { id: result.id, name: result.name, message: 'Workflow created' };
547
+ }
548
+ case 'update_workflow': {
549
+ const body = {};
550
+ if (toolArgs.name) body.name = toolArgs.name;
551
+ if (toolArgs.nodes) body.nodes = toolArgs.nodes;
552
+ if (toolArgs.connections) body.connections = toolArgs.connections;
553
+ if (toolArgs.settings) body.settings = toolArgs.settings;
554
+ const result = await n8nRequest('PATCH', `/workflows/${toolArgs.workflow_id}`, body);
555
+ return { id: result.id, name: result.name, message: 'Workflow updated' };
556
+ }
557
+ case 'delete_workflow':
558
+ await n8nRequest('DELETE', `/workflows/${toolArgs.workflow_id}`);
559
+ return { success: true, message: 'Workflow deleted' };
560
+ case 'activate_workflow': {
561
+ const result = await n8nRequest('PATCH', `/workflows/${toolArgs.workflow_id}`, { active: true });
562
+ return { id: result.id, active: result.active, message: 'Workflow activated' };
563
+ }
564
+ case 'deactivate_workflow': {
565
+ const result = await n8nRequest('PATCH', `/workflows/${toolArgs.workflow_id}`, { active: false });
566
+ return { id: result.id, active: result.active, message: 'Workflow deactivated' };
567
+ }
568
+ case 'execute_workflow': {
569
+ const body = toolArgs.data ? { data: toolArgs.data } : {};
570
+ const result = await n8nRequest('POST', `/workflows/${toolArgs.workflow_id}/run`, body);
571
+ return { executionId: result.data?.executionId, success: result.data?.finished };
572
+ }
573
+ case 'list_executions': {
574
+ const params = new URLSearchParams();
575
+ if (toolArgs.workflow_id) params.set('workflowId', toolArgs.workflow_id);
576
+ if (toolArgs.status) params.set('status', toolArgs.status);
577
+ if (toolArgs.limit) params.set('limit', String(toolArgs.limit));
578
+ const queryString = params.toString() ? `?${params.toString()}` : '';
579
+ const result = await n8nRequest('GET', `/executions${queryString}`);
580
+ return { executions: result.data?.map(e => ({ id: e.id, workflowId: e.workflowId, status: e.status })) || result };
581
+ }
582
+ case 'get_execution': {
583
+ const result = await n8nRequest('GET', `/executions/${toolArgs.execution_id}`);
584
+ return { id: result.id, workflowId: result.workflowId, status: result.status };
585
+ }
586
+ case 'delete_execution':
587
+ await n8nRequest('DELETE', `/executions/${toolArgs.execution_id}`);
588
+ return { success: true, message: 'Execution deleted' };
589
+ case 'list_tags': {
590
+ const result = await n8nRequest('GET', '/tags');
591
+ return { tags: result.data?.map(t => ({ id: t.id, name: t.name })) || result };
592
+ }
593
+ case 'create_tag': {
594
+ const result = await n8nRequest('POST', '/tags', { name: toolArgs.name });
595
+ return { id: result.id, name: result.name };
596
+ }
597
+ default:
598
+ throw new Error(`Unknown n8n tool: ${toolName}`);
599
+ }
600
+ }
601
+
602
+ app.post('/plugins/n8n/list', async (req, res) => {
603
+ res.json({ tools: N8N_TOOLS });
604
+ });
605
+
606
+ app.post('/plugins/n8n/call', async (req, res) => {
607
+ const { toolName, toolArgs, env } = req.body;
608
+ if (!toolName) {
609
+ return res.status(400).json({ error: 'Missing toolName' });
610
+ }
611
+ try {
612
+ const result = await handleN8nTool(toolName, toolArgs || {}, env || {});
613
+ const formattedResult = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result);
614
+ res.json({ content: [{ type: 'text', text: formattedResult }], isError: false });
615
+ } catch (error) {
616
+ res.json({ content: [{ type: 'text', text: `n8n error: ${error.message}` }], isError: true });
617
+ }
618
+ });
619
+
620
+ // ============ OAUTH ENDPOINTS ============
621
+
622
+ app.post('/oauth/register', async (req, res) => {
623
+ const { registration_endpoint, ...params } = req.body;
624
+ if (!registration_endpoint) {
625
+ return res.status(400).json({ error: 'Missing registration_endpoint' });
626
+ }
627
+ try {
628
+ const response = await fetch(registration_endpoint, {
629
+ method: 'POST',
630
+ headers: { 'Content-Type': 'application/json' },
631
+ body: JSON.stringify(params),
632
+ });
633
+ const data = await response.json();
634
+ res.status(response.status).json(data);
635
+ } catch (error) {
636
+ res.status(500).json({ error: 'Registration failed', message: error.message });
637
+ }
638
+ });
639
+
640
+ app.post('/oauth/token', async (req, res) => {
641
+ const { token_endpoint, ...params } = req.body;
642
+ if (!token_endpoint) {
643
+ return res.status(400).json({ error: 'Missing token_endpoint' });
644
+ }
645
+ try {
646
+ const response = await fetch(token_endpoint, {
647
+ method: 'POST',
648
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
649
+ body: new URLSearchParams(params).toString(),
650
+ });
651
+ const data = await response.json();
652
+ res.status(response.status).json(data);
653
+ } catch (error) {
654
+ res.status(500).json({ error: 'Token exchange failed', message: error.message });
655
+ }
656
+ });
657
+
658
+ // ============ SPA FALLBACK ============
659
+
660
+ // Express 5 catch-all for SPA routes (not matched by API routes)
661
+ app.use((req, res, next) => {
662
+ // Only handle GET requests that weren't matched by other routes
663
+ if (req.method === 'GET' && !req.path.startsWith('/api') && !req.path.startsWith('/mcp') && !req.path.startsWith('/plugins') && !req.path.startsWith('/oauth') && !req.path.startsWith('/health')) {
664
+ const indexPath = join(distDir, 'index.html');
665
+ if (existsSync(indexPath)) {
666
+ res.sendFile(indexPath);
667
+ } else {
668
+ res.status(404).send('Application not found. Please run "npm run build" first.');
669
+ }
670
+ } else {
671
+ next();
672
+ }
673
+ });
674
+
675
+ // ============ CLEANUP & START ============
676
+
677
+ process.on('SIGINT', () => {
678
+ console.log('\n Shutting down MCP servers...');
679
+ for (const [id, server] of stdioServers) {
680
+ server.stop();
681
+ }
682
+ process.exit(0);
683
+ });
684
+
685
+ process.on('SIGTERM', () => {
686
+ for (const [id, server] of stdioServers) {
687
+ server.stop();
688
+ }
689
+ process.exit(0);
690
+ });
691
+
692
+ app.listen(PORT, () => {
693
+ console.log(` \x1b[32m✓\x1b[0m Server running at \x1b[36mhttp://localhost:${PORT}\x1b[0m`);
694
+ console.log('');
695
+ console.log(' Press Ctrl+C to stop');
696
+ console.log('');
697
+ });