index.js•86.7 kB
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import path from 'path';
import OpenAI from 'openai';
import { Anthropic } from '@anthropic-ai/sdk';
import { GoogleGenerativeAI } from '@google/generative-ai';
import session from 'express-session';
import { google } from 'googleapis';
import { Octokit } from '@octokit/rest';
import multer from 'multer';
import fs from 'fs';
import bodyParser from 'body-parser';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import { jwtDecode } from "jwt-decode";
import { parseIntent } from './parser.js';
import crypto from 'crypto';
import { getCredentials as daoGetCredentials, saveCredentials as daoSaveCredentials, deleteCredentials as daoDeleteCredentials } from './services/credentialDao.js';
import slackProvider from './providers/slack.js';
import cursorProvider from './providers/cursor.js';
import twentyFirstDevProvider from './providers/twentyFirstDev.js';
import boltProvider from './providers/bolt.js';
// ─── Jira & Notion providers ──────────────────────────────
import jiraProvider from './providers/jira.js';
import notionProvider from './providers/notion.js';
import axios from 'axios';
// Load env vars – first from current dir, then parent as fallback
dotenv.config();
if (!process.env.ATLASSIAN_CLIENT_ID) {
dotenv.config({ path: path.resolve(process.cwd(), '../.env') });
}
const app = express();
app.use(cors({
origin: (origin, callback) => {
const allowList = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',').map(o => o.trim());
if (!origin || allowList.includes(origin)) {
return callback(null, true);
}
callback(new Error('Origin not allowed by CORS'));
},
credentials: true
}));
app.use(bodyParser.json({ limit: '10mb' }));
app.use(express.urlencoded({ limit: '10mb', extended: true }));
// Session setup
app.use(session({
secret: process.env.SESSION_SECRET || 'mcp-secret',
resave: false,
saveUninitialized: false,
cookie: {
sameSite: 'lax', // 'lax' is best for local dev, 'none' for HTTPS
secure: false, // true if using HTTPS
}
}));
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
'http://localhost:3001/auth/google/callback'
);
// Expanded scopes so a single OAuth consent covers Gmail (read/send), Drive, and Calendar
const GOOGLE_SCOPES = [
// Drive (read/write - limits to files created or opened by this app)
'https://www.googleapis.com/auth/drive.file',
// Gmail read & send
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
// Calendar read/write
'https://www.googleapis.com/auth/calendar',
// Basic profile info
'profile',
'email'
];
// --- GitHub OAuth ---
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
const GITHUB_CALLBACK = 'http://localhost:3001/auth/github/callback';
// --- Atlassian Jira OAuth (PKCE) & Notion OAuth --------------------------------
const ATLASSIAN_CLIENT_ID = process.env.ATLASSIAN_CLIENT_ID || '';
const JIRA_SCOPES = 'read:jira-user read:jira-work write:jira-work offline_access';
const JIRA_REDIRECT_URI = process.env.JIRA_REDIRECT_URI || 'http://localhost:3001/api/auth/jira/callback';
const NOTION_CLIENT_ID = process.env.NOTION_CLIENT_ID || '';
const NOTION_CLIENT_SECRET = process.env.NOTION_CLIENT_SECRET || '';
const NOTION_REDIRECT_URI = process.env.NOTION_REDIRECT_URI || 'http://localhost:3001/api/auth/notion/callback';
// PKCE helpers for Atlassian
function base64urlEncode(buf) {
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function generateCodeVerifier() {
return base64urlEncode(crypto.randomBytes(32));
}
function sha256(buffer) {
return crypto.createHash('sha256').update(buffer).digest();
}
function generateCodeChallenge(verifier) {
return base64urlEncode(sha256(Buffer.from(verifier)));
}
// ── Jira alias routes (rewrite, no redirect) ─────────
app.get('/api/jira/url', (req, res, next) => {
req.url = '/api/auth/jira/url';
next();
});
app.get('/api/jira/status', (req, res, next) => {
req.url = '/api/auth/jira/status';
next();
});
app.post('/api/jira/disconnect', (req, res, next) => {
req.url = '/api/auth/jira/disconnect';
next();
});
// ─── Jira OAuth endpoints ────────────────────────────────
app.get('/api/auth/jira/url', (req, res) => {
if (!ATLASSIAN_CLIENT_ID) return res.status(500).json({ error: 'Missing ATLASSIAN_CLIENT_ID' });
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = crypto.randomBytes(16).toString('hex');
req.session.jiraOauth = { codeVerifier, state };
const params = new URLSearchParams({
audience: 'api.atlassian.com',
client_id: ATLASSIAN_CLIENT_ID,
scope: JIRA_SCOPES,
redirect_uri: JIRA_REDIRECT_URI,
state,
response_type: 'code',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
prompt: 'consent'
});
const authorizationUrl = `https://auth.atlassian.com/authorize?${params.toString()}`;
// Return both for backward-compat but prefer authorizationUrl
return res.json({ authorizationUrl, url: authorizationUrl });
});
// Live-status helper (auth namespace for UI convenience)
app.get('/api/auth/jira/status', async (req, res) => {
try {
const creds = await daoGetCredentials('jira');
res.json({ connected: !!creds });
} catch (err) {
res.status(500).json({ connected: false, error: err.message });
}
});
// Disconnect – removes stored Jira tokens
app.post('/api/auth/jira/disconnect', async (req, res) => {
try {
await daoDeleteCredentials('jira');
broadcastEvent('token-disconnect', { provider: 'jira' });
res.json({ disconnected: true });
} catch (err) {
res.status(500).json({ disconnected: false, error: err.message });
}
});
app.get('/api/auth/jira/callback', async (req, res) => {
try {
const { code, state } = req.query;
if (!code || !state) return res.status(400).send('Missing code/state');
if (!req.session.jiraOauth || req.session.jiraOauth.state !== state) {
return res.status(400).send('Invalid state');
}
const { codeVerifier } = req.session.jiraOauth;
delete req.session.jiraOauth;
// Exchange code → tokens
const tokenBody = {
grant_type: 'authorization_code',
client_id: ATLASSIAN_CLIENT_ID,
code,
redirect_uri: JIRA_REDIRECT_URI,
code_verifier: codeVerifier
};
if (process.env.ATLASSIAN_CLIENT_SECRET) {
tokenBody.client_secret = process.env.ATLASSIAN_CLIENT_SECRET;
}
const tokenResp = await axios.post('https://auth.atlassian.com/oauth/token', tokenBody, { headers: { 'Content-Type': 'application/json' } });
const { access_token, refresh_token, expires_in, scope } = tokenResp.data;
// Determine cloudId
const res2 = await axios.get('https://api.atlassian.com/oauth/token/accessible-resources', {
headers: { Authorization: `Bearer ${access_token}` }
});
const cloudId = Array.isArray(res2.data) && res2.data[0]?.id;
await daoSaveCredentials('jira', encryptCredentialsObj({
access_token,
refresh_token,
expires_at: Date.now() + expires_in * 1000,
scope,
cloud_id: cloudId
}));
broadcastEvent('token-connect', { provider: 'jira' });
const frontendBase = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',')[0];
const redirect = `${frontendBase}/?provider=jira&status=success`;
return res.redirect(302, redirect);
} catch (err) {
if (err.response) {
console.error('[Jira OAuth] callback', err.response.status, err.response.data || err.response.statusText);
} else {
console.error('[Jira OAuth] callback', err.message);
}
res.status(500).send('Jira OAuth failed');
}
});
// Helper to ensure Jira token freshness
async function ensureJiraAccessToken() {
let stored = await daoGetCredentials('jira');
if (!stored) return null;
stored = decryptCredentialsObj(stored);
if (!stored.access_token) return null;
const FIVE_MIN = 5 * 60 * 1000;
if (stored.expires_at && stored.expires_at - Date.now() < FIVE_MIN) {
try {
const r = await axios.post('https://auth.atlassian.com/oauth/token', {
grant_type: 'refresh_token',
client_id: ATLASSIAN_CLIENT_ID,
refresh_token: stored.refresh_token
}, { headers: { 'Content-Type': 'application/json' } });
const { access_token, refresh_token, expires_in } = r.data;
stored.access_token = access_token;
if (refresh_token) stored.refresh_token = refresh_token;
stored.expires_at = Date.now() + expires_in * 1000;
await daoSaveCredentials('jira', encryptCredentialsObj(stored));
broadcastEvent('token-refresh', { provider: 'jira', refreshed: true });
} catch (e) {
console.error('[Jira] refresh failed', e.message);
}
}
return { accessToken: stored.access_token, cloudId: stored.cloud_id };
}
// ─── Notion OAuth endpoints ──────────────────────────────
app.get('/api/auth/notion/url', (req, res) => {
if (!NOTION_CLIENT_ID) return res.status(500).json({ error: 'Missing NOTION_CLIENT_ID' });
const state = crypto.randomBytes(16).toString('hex');
req.session.notionState = state;
const params = new URLSearchParams({
client_id: NOTION_CLIENT_ID,
redirect_uri: NOTION_REDIRECT_URI,
response_type: 'code',
owner: 'user',
state
});
res.json({ url: `https://api.notion.com/v1/oauth/authorize?${params.toString()}` });
});
app.get('/api/auth/notion/callback', async (req, res) => {
const { code, state } = req.query;
if (!code || !state) return res.status(400).send('Missing code/state');
if (state !== req.session.notionState) return res.status(400).send('Invalid state');
delete req.session.notionState;
try {
const tokenResp = await axios.post('https://api.notion.com/v1/oauth/token', {
grant_type: 'authorization_code',
code,
redirect_uri: NOTION_REDIRECT_URI,
client_id: NOTION_CLIENT_ID,
client_secret: NOTION_CLIENT_SECRET
}, { headers: { 'Content-Type': 'application/json' } });
const { access_token, bot_id, workspace_id } = tokenResp.data;
await daoSaveCredentials('notion', encryptCredentialsObj({
access_token,
bot_id,
workspace_id
}));
broadcastEvent('token-connect', { provider: 'notion' });
const redirect = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',')[0] + '/integrations?provider=notion&status=success';
res.redirect(302, redirect);
} catch (err) {
console.error('[Notion OAuth] callback error', err.message);
res.status(500).send('Notion OAuth failed');
}
});
// --- MCP Protocol Implementation ---
// Command Registry - MCP compliant
const MCP_COMMANDS = [
{ id: 'chat', name: 'Chat', description: 'Conversational AI', providers: ['openai', 'anthropic', 'gemini'] },
{ id: 'summarize', name: 'Summarize', description: 'Summarize text or documents', providers: ['openai', 'anthropic', 'gemini'] },
{ id: 'generate-code', name: 'Generate Code', description: 'Generate code snippets', providers: ['openai', 'anthropic', 'gemini'] },
{ id: 'explain', name: 'Explain', description: 'Explain concepts or code', providers: ['openai', 'anthropic', 'gemini'] },
{ id: 'translate', name: 'Translate', description: 'Translate text to different languages', providers: ['openai', 'anthropic', 'gemini'] },
{ id: 'list-repos', name: 'List Repositories', description: 'List GitHub repositories', providers: ['github'] },
{ id: 'get-file', name: 'Get File', description: 'Fetch file content from repository', providers: ['github'] },
{ id: 'repo-summary', name: 'Repository Summary', description: 'Summarize repository structure and content', providers: ['github'] },
{ id: 'code-search', name: 'Code Search', description: 'Search code in repositories', providers: ['github'] },
{ id: 'generate-issue', name: 'Generate Issue', description: 'Create GitHub issue', providers: ['github'] },
{ id: 'generate-pr', name: 'Generate PR', description: 'Create GitHub pull request', providers: ['github'] },
{ id: 'list-messages', name: 'List Gmail messages', description: 'List recent Gmail messages', providers: ['gmail'] },
{ id: 'get-message', name: 'Get Gmail message', description: 'Fetch Gmail message by ID', providers: ['gmail'] },
{ id: 'send-email', name: 'Send Email', description: 'Send an email via Gmail', providers: ['gmail'] },
{ id: 'list-files', name: 'List Drive Files', description: 'List Google Drive files', providers: ['google_drive'] },
{ id: 'search-files', name: 'Search Drive Files', description: 'Search Google Drive files', providers: ['google_drive'] },
{ id: 'download-file', name: 'Download Drive File', description: 'Download file content from Drive', providers: ['google_drive'] },
{ id: 'upload-file', name: 'Upload Drive File', description: 'Upload a new file to Drive', providers: ['google_drive'] },
{ id: 'list-events', name: 'List Calendar Events', description: 'List today\'s Google Calendar events', providers: ['google_calendar'] },
{ id: 'generate-ui-component', name: 'Generate UI Component', description: 'Generate front-end UI component code', providers: ['21st_dev'] },
{ id: 'web-search', name: 'Web Search', description: 'Perform a web search via Bolt', providers: ['bolt'] },
{ id: 'figma-action', name: 'Figma Action', description: 'Interact with Figma via Bolt', providers: ['bolt'] },
{ id: 'custom-tool', name: 'Custom Tool', description: 'Run a custom Bolt tool', providers: ['bolt'] },
{ id: 'send-message', name: 'Slack Send message', description: 'Send Slack message', providers: ['slack'] },
{ id: 'list-channels', name: 'Slack List channels', description: 'List Slack channels', providers: ['slack'] },
{ id: 'get-channel-history', name: 'Slack Get messages', description: 'Channel history', providers: ['slack'] },
{ id: 'list-projects', name: 'Jira List projects', description: 'List Jira projects', providers: ['jira'] },
{ id: 'create-issue', name: 'Jira Create issue', description: 'Create Jira issue', providers: ['jira'] },
{ id: 'list-databases', name: 'Notion List DBs', description: 'List Notion databases', providers: ['notion'] },
{ id: 'query-database', name: 'Notion Query DB', description: 'Query Notion database', providers: ['notion'] },
];
// After the MCP_COMMANDS array is declared, insert the server version constant
const MCP_SERVER_VERSION = '2024-07-01';
// Maximum number of recent chat turns we keep when forwarding to LLMs
const HISTORY_LIMIT = 6;
// MCP Provider Plugin Interface
class ProviderPlugin {
constructor(options = {}) {
this.options = options;
}
id = 'base';
name = 'Base Provider';
supportedCommands = [];
async executeCommand({ prompt, command, context, apiKey }) {
throw new Error('executeCommand must be implemented by provider');
}
async listResources(params) {
throw new Error('listResources not implemented');
}
}
// GitHub Provider Plugin - MCP compliant
class GitHubProviderPlugin extends ProviderPlugin {
id = 'github';
name = 'GitHub';
supportedCommands = ['list-repos', 'get-file', 'repo-summary', 'code-search', 'generate-issue', 'generate-pr'];
async executeCommand({ prompt, command, context, apiKey }) {
if (!apiKey) {
throw new Error('GitHub API key (Personal Access Token) is required.');
}
const octokit = new Octokit({ auth: apiKey });
// Normalize command: lower-case and replace spaces with dashes
const cmd = (command || '')
.toLowerCase()
.replace(/[\s_]+/g, '-'); // convert spaces or underscores to dashes
switch (cmd) {
case 'list-repos': {
const { data } = await octokit.repos.listForAuthenticatedUser({ per_page: 100 });
const repoNames = data.map(repo => repo.full_name).join('\n');
return {
output: `Your repositories:\n${repoNames}`,
tokens_used: null,
model_version: 'github-api-v3',
provider: this.id,
command,
raw_response: data
};
}
case 'get-file': {
// Parse prompt for owner/repo/path
const match = prompt.match(/([^\/]+)\/([^\/]+)\/(.+)/);
if (!match) {
throw new Error('Format: owner/repo/path/to/file');
}
const [, owner, repo, path] = match;
const { data } = await octokit.repos.getContent({ owner, repo, path });
const content = Buffer.from(data.content, 'base64').toString();
return {
output: content,
tokens_used: null,
model_version: 'github-api-v3',
provider: this.id,
command,
raw_response: data
};
}
case 'repo-summary': {
// Extract first owner/repo pattern from prompt
const matchRepo = prompt.match(/([\w.-]+\/[\w.-]+)/);
if (!matchRepo) throw new Error('Prompt must contain owner/repo');
const [owner, repo] = matchRepo[1].split('/');
if (!owner || !repo) throw new Error('Format repo as owner/repo');
const { data: contents } = await octokit.repos.getContent({ owner, repo, path: '' });
// List first 100 top-level files/dirs
const listing = (Array.isArray(contents) ? contents : []).slice(0, 100).map(f => `${f.type}: ${f.name}`).join('\n');
const summary = `Top-level contents of ${owner}/${repo}:\n${listing}`;
return {
output: summary,
tokens_used: null,
model_version: 'github-api-v3',
provider: this.id,
command,
raw_response: contents
};
}
case 'code-search': {
// Expect prompt like "searchTerm in owner/repo"
const match = prompt.match(/^(.*?)\s+in\s+([\w.-]+\/[\w.-]+)/i);
if (!match) throw new Error('Format: <searchTerm> in owner/repo');
const [, query, repoRef] = match;
const [owner, repo] = repoRef.split('/');
const { data } = await octokit.search.code({ q: `${query} repo:${owner}/${repo}`, per_page: 10 });
const results = data.items.map(i => `${i.path} (${i.html_url})`).join('\n');
return {
output: results || 'No matches found.',
tokens_used: null,
model_version: 'github-api-v3',
provider: this.id,
command,
raw_response: data
};
}
case 'generate-issue': {
// Expect prompt format: owner/repo | title | body (body optional)
const parts = prompt.replace(/^\s*\/github\s+generate-issue/i,'').split('|').map(p=>p.trim()).filter(Boolean);
if (parts.length < 2) {
// try plain language: "create issue in owner/repo: title - body"
let plainMatch = prompt.match(/issue\s+(?:in|on)\s+([\w.-]+\/[\w.-]+)\s*[:\-]\s*(.+)/i);
if (!plainMatch) {
// try without explicit : or - delimiter
plainMatch = prompt.match(/issue\s+(?:in|on)\s+([\w.-]+\/[\w.-]+)\s+(.+)/i);
}
if (!plainMatch) throw new Error('Format: owner/repo | title | [body]');
const titleBody = plainMatch[2].trim();
const split = titleBody.split(/[-–—]/);
const title = split[0].trim();
const bodyRest = split.slice(1).join('-').trim();
parts.length = 0;
parts.push(plainMatch[1].trim(), title, bodyRest);
}
const [repoRef, issueTitle, issueBody] = parts;
const matchRepo = repoRef.match(/([\w.-]+)\/([\w.-]+)/);
if (!matchRepo) throw new Error('First segment must be owner/repo');
const owner = matchRepo[1];
const repo = matchRepo[2];
if (!owner || !repo) throw new Error('Repo must be owner/repo');
const { data } = await octokit.issues.create({ owner, repo, title: issueTitle, body: issueBody || '' });
return {
output: `✅ Issue created: ${data.html_url}`,
provider: this.id,
command,
raw_response: data
};
}
case 'generate-pr': {
// Expect prompt: owner/repo | headBranch | baseBranch | title | body(optional)
const parts = prompt.replace(/^\s*\/github\s+generate-pr/i,'').split('|').map(p=>p.trim()).filter(Boolean);
if (parts.length < 4) {
const plain = prompt.match(/pr\s+from\s+(\S+)\s+into\s+(\S+)\s+on\s+([\w.-]+\/[\w.-]+)\s*[:\-]?\s*(.+)/i);
if (!plain) throw new Error('Format: owner/repo | head | base | title | [body]');
// plain[4] contains title+optional body separated by dash
const tb = plain[4].trim();
const s = tb.split(/[-–—]/);
const tTitle = s[0].trim();
const tBody = s.slice(1).join('-').trim();
parts.length=0;
parts.push(plain[3], plain[1], plain[2], tTitle, tBody);
}
const [repoRef, head, base, prTitle, prBody] = parts;
const [owner, repo] = repoRef.split('/');
if (!owner || !repo) throw new Error('Repo must be owner/repo');
const { data } = await octokit.pulls.create({ owner, repo, head, base, title: prTitle, body: prBody || '' });
return {
output: `✅ Pull request created: ${data.html_url}`,
provider: this.id,
command,
raw_response: data
};
}
default:
throw new Error(`Unknown command: ${command}`);
}
}
async listResources({ apiKey }) {
if (!apiKey) {
throw new Error('GitHub API key required');
}
const octokit = new Octokit({ auth: apiKey });
const { data } = await octokit.repos.listForAuthenticatedUser({ per_page: 100 });
return data.map(repo => ({
id: repo.id,
name: repo.full_name,
type: 'repository',
url: repo.html_url,
description: repo.description
}));
}
}
// Gmail Provider Plugin - MCP compliant
class GmailProviderPlugin extends ProviderPlugin {
id = 'gmail';
name = 'Gmail';
supportedCommands = ['list-messages', 'get-message', 'send-email'];
// helper to build Gmail client from OAuth bearer token
buildGmailClient(accessToken) {
const authClient = new google.auth.OAuth2();
authClient.setCredentials({ access_token: accessToken });
return google.gmail({ version: 'v1', auth: authClient });
}
async executeCommand({ prompt, command, apiKey /* here apiKey carries accessToken */ }) {
if (!apiKey) {
throw new Error('Google OAuth access token required.');
}
const gmail = this.buildGmailClient(apiKey);
// Accept both "get-message" and "get-message <id>" (with id appended)
const parts = (command || '').trim().split(/\s+/);
const cmdBase = parts[0].toLowerCase().replace(/[\s_]+/g, '-');
const argString = parts.slice(1).join(' '); // rest of command after first token
const cmd = cmdBase;
switch (cmd) {
case 'list-messages': {
// First fetch message IDs (minimal)
const listRes = await gmail.users.messages.list({ userId: 'me', maxResults: 20 });
const msgs = listRes.data.messages || [];
// Fetch metadata headers for each message in parallel (but capped)
const detailed = await Promise.all(msgs.slice(0, 20).map(async (m) => {
try {
const meta = await gmail.users.messages.get({
userId: 'me',
id: m.id,
format: 'metadata',
metadataHeaders: ['Subject', 'From', 'Date']
});
const headers = (meta.data.payload?.headers || []).reduce((acc, h) => { acc[h.name.toLowerCase()] = h.value; return acc; }, {});
return {
id: meta.data.id,
threadId: meta.data.threadId,
subject: headers['subject'] || '',
from: headers['from'] || '',
date: headers['date'] || '',
snippet: meta.data.snippet || ''
};
} catch {
return { id: m.id };
}
}));
return { output: detailed, provider: this.id, command: cmd, raw_response: detailed };
}
case 'get-message': {
const messageId = argString;
if (!messageId) throw new Error('Prompt must contain Gmail message ID');
const { data } = await gmail.users.messages.get({ userId: 'me', id: messageId, format: 'full' });
const headers = (data.payload?.headers || []).reduce((acc, h) => { acc[h.name.toLowerCase()] = h.value; return acc; }, {});
const simplified = {
id: data.id,
threadId: data.threadId,
subject: headers['subject'] || '',
from: headers['from'] || '',
date: headers['date'] || '',
snippet: data.snippet || '',
body: ((data.payload?.parts || []).find(p=>p.mimeType==='text/plain')?.body?.data) || ''
};
return { output: simplified, provider: this.id, command: cmd, raw_response: data };
}
case 'send-email': {
// Expect prompt as JSON: { to, subject, body }
let payload;
try { payload = JSON.parse(prompt); }
catch { throw new Error('Prompt must be JSON with to, subject, body'); }
const { to, subject, body } = payload;
if (!to || !subject || !body) throw new Error('to, subject, body required');
const emailLines = [
`To: ${to}`,
'Content-Type: text/plain; charset="UTF-8"',
`Subject: ${subject}`,
'',
body
];
const encodedMessage = Buffer.from(emailLines.join('\n'))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const { data } = await gmail.users.messages.send({ userId: 'me', requestBody: { raw: encodedMessage } });
return { output: 'Email sent.', provider: this.id, command: cmd, raw_response: data };
}
default:
throw new Error(`Unknown command: ${command}`);
}
}
}
// Initialize provider plugins
const githubProviderPlugin = new GitHubProviderPlugin();
const gmailProviderPlugin = new GmailProviderPlugin();
// MCP Endpoints
// Get available commands - MCP compliant
app.get('/api/commands', (req, res) => {
res.json(MCP_COMMANDS);
});
// Get provider information - MCP extension
app.get('/api/providers', (req, res) => {
const providers = [
{
id: 'openai',
name: 'OpenAI',
supportedCommands: ['chat', 'summarize', 'generate-code', 'explain', 'translate'],
requiresApiKey: true
},
{
id: 'anthropic',
name: 'Anthropic',
supportedCommands: ['chat', 'summarize', 'generate-code', 'explain', 'translate'],
requiresApiKey: true
},
{
id: 'gemini',
name: 'Gemini',
supportedCommands: ['chat', 'summarize', 'generate-code', 'explain', 'translate'],
requiresApiKey: true
},
{
id: cursorProvider.id,
name: cursorProvider.name,
supportedCommands: cursorProvider.supportedCommands,
requiresApiKey: true
},
{
id: twentyFirstDevProvider.id,
name: twentyFirstDevProvider.name,
supportedCommands: twentyFirstDevProvider.supportedCommands,
requiresApiKey: true
},
{
id: boltProvider.id,
name: boltProvider.name,
supportedCommands: boltProvider.supportedCommands,
requiresApiKey: true
},
{
id: githubProviderPlugin.id,
name: githubProviderPlugin.name,
supportedCommands: githubProviderPlugin.supportedCommands,
requiresApiKey: true
},
{
id: gmailProviderPlugin.id,
name: gmailProviderPlugin.name,
supportedCommands: gmailProviderPlugin.supportedCommands,
requiresApiKey: true
},
{
id: notionProvider.id,
name: 'Notion',
supportedCommands: notionProvider.supportedCommands,
requiresApiKey: true
}
];
res.json(providers);
});
// Get resources for a provider - MCP extension
app.get('/api/providers/:providerId/resources', async (req, res) => {
const { providerId } = req.params;
const { apiKey } = req.query;
try {
if (providerId === 'github') {
const resources = await githubProviderPlugin.listResources({ apiKey });
res.json({ resources });
} else if (providerId === 'gmail') {
const resources = await gmailProviderPlugin.listResources({ apiKey });
res.json({ resources });
} else if (providerId === 'cursor') {
const resources = await cursorProvider.listResources({ apiKey });
res.json({ resources });
} else if (providerId === '21st_dev') {
const resources = await twentyFirstDevProvider.listResources({ apiKey });
res.json({ resources });
} else if (providerId === 'bolt') {
const resources = await boltProvider.listResources({ apiKey });
res.json({ resources });
} else if (providerId === 'notion') {
const resources = await notionProvider.listResources({ apiKey });
res.json({ resources });
} else {
res.status(400).json({ error: 'Provider does not support resource listing' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// MCP compatibility - commands endpoint already defined above
app.get('/auth/google', (req, res) => {
const url = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: GOOGLE_SCOPES,
prompt: 'consent',
});
res.redirect(url);
});
app.get('/auth/google/callback', async (req, res) => {
const { code } = req.query;
if (!code) return res.status(400).send('No code provided');
try {
const { tokens } = await oauth2Client.getToken(code);
req.session.googleTokens = tokens;
// Persist refresh_token using Credential DAO
if (tokens.refresh_token) {
await daoSaveCredentials('google', { refreshToken: encryptValue(tokens.refresh_token) });
}
res.send('Google authentication successful! You can close this window.');
} catch (err) {
console.error('Google OAuth error:', err);
res.status(500).send('Google authentication failed. Please try again.');
}
});
async function ensureGoogleTokens(req) {
const now = Date.now();
// 1. If we already have valid session tokens not expiring soon, reuse
if (req.session.googleTokens && (req.session.googleTokens.expiry_date ?? 0) - now > 120_000) {
return req.session.googleTokens;
}
// 2. Determine refresh token: preference session value, else DAO, else legacy DB
let refreshToken = req.session.googleTokens?.refresh_token;
if (!refreshToken) {
const creds = await daoGetCredentials('google');
if (creds?.refreshToken) {
refreshToken = decryptValue(creds.refreshToken);
} else {
try {
const user = await getOrCreateUser();
if (user.googleRefreshToken) {
refreshToken = decryptValue(user.googleRefreshToken);
// migrate to DAO for future usage
await daoSaveCredentials('google', { refreshToken: encryptValue(refreshToken) });
}
} catch { /* ignore */ }
}
}
if (!refreshToken) return null;
const client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
'http://localhost:3001/auth/google/callback'
);
client.setCredentials({ refresh_token: refreshToken });
try {
const { credentials } = await client.refreshAccessToken();
req.session.googleTokens = {
refresh_token: refreshToken,
access_token: credentials.access_token,
expiry_date: credentials.expiry_date || (now + 3_300_000)
};
try {
await daoSaveCredentials('google', {
refreshToken: encryptValue(refreshToken),
accessToken: encryptValue(credentials.access_token),
expiresAt: credentials.expiry_date || (now + 3_300_000)
});
} catch {
/* ignore persistence errors */
}
return req.session.googleTokens;
} catch (err) {
console.error('Google refresh failed:', err.message);
return null;
}
}
// --- Bearer token auth middleware for Google Drive commands ---
async function requireAuth(req, res, next) {
// Only enforce auth for Google Drive/Calendar commands
if (!['google_drive', 'google_calendar'].includes(req.body?.provider)) {
return next();
}
// 1. Prefer explicit Bearer token
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ')) {
req.googleToken = auth.replace('Bearer ', '');
return next();
}
// 2. Attempt to ensure/refresh session token via refresh_token
await ensureGoogleTokens(req);
if (req.session.googleTokens?.access_token) {
req.googleToken = req.session.googleTokens.access_token;
return next();
}
// No token available
return res.status(401).json({ error: 'Missing Google OAuth token' });
}
app.post(['/api/command', '/command'], requireAuth, async (req, res) => {
// Log only high-level details to avoid dumping full conversation to console
let { prompt, provider, command, apiKey } = req.body;
const cmdIdHeader = req.headers['x-mcp-command-id'];
const cmdId = Array.isArray(cmdIdHeader) ? cmdIdHeader[0] : (cmdIdHeader || crypto.randomUUID());
// initial progress event
broadcastEvent('command-progress', {
id: cmdId,
provider,
command,
percent: 0,
stage: 'start',
message: 'queued'
});
// emit complete once response finishes
res.once('finish', () => {
const ok = res.statusCode < 400;
broadcastEvent('command-complete', {
id: cmdId,
provider,
command,
success: ok,
error: ok ? undefined : res.locals?.errorMessage
});
});
// Fallback: if no apiKey field provided use Bearer token from Authorization header
if (!apiKey && typeof req.headers.authorization === 'string' && req.headers.authorization.startsWith('Bearer ')) {
apiKey = req.headers.authorization.replace('Bearer ', '').trim();
}
console.log('Received /api/command', { provider, command, promptLength: prompt?.length, hasApiKey: !!apiKey, msgCount: Array.isArray(req.body.messages) ? req.body.messages.length : 0 });
let formattedPrompt = prompt;
switch (command) {
case 'summarize':
formattedPrompt = `Summarize this:\n${prompt}`;
break;
case 'generate-code':
formattedPrompt = `Write code for:\n${prompt}`;
break;
case 'explain':
formattedPrompt = `Explain this:\n${prompt}`;
break;
case 'translate':
formattedPrompt = `Translate this to English:\n${prompt}`;
break;
default:
// 'chat' or unknown, use as-is
break;
}
try {
if (provider === 'openai') {
console.log(`[MCP -> OpenAI] command=${command}`);
const openaiApiKey = apiKey || process.env.OPENAI_API_KEY;
if (!openaiApiKey) {
return res.status(400).json({ error: 'No OpenAI API key provided.' });
}
const openai = new OpenAI({ apiKey: openaiApiKey });
// --- TRIM THE MESSAGES ARRAY TO LAST 10 ---
let messagesToSend = req.body.messages;
if (!messagesToSend) {
messagesToSend = [{ role: 'user', content: formattedPrompt }];
}
if (Array.isArray(messagesToSend) && messagesToSend.length > HISTORY_LIMIT) {
messagesToSend = messagesToSend.slice(-HISTORY_LIMIT);
}
try {
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: messagesToSend,
max_tokens: 1024,
});
return res.json({
output: completion.choices[0].message.content,
tokens_used: completion.usage.total_tokens,
model_version: 'gpt-3.5-turbo',
provider: 'openai',
raw_response: completion,
});
} catch (err) {
// Detect and surface rate-limit errors clearly so the UI can show an informative message
const message = err?.message || 'Unknown OpenAI error';
// OpenAI client exposes status via err.status, fall back to 429 keyword in message if absent
const status = err?.status || (message.includes('rate') && 429);
if (status === 429) {
return res.status(429).json({ error: 'OpenAI rate limit exceeded. Please wait a moment and try again.' });
}
// Otherwise bubble up generic error
return res.status(400).json({ error: message });
}
} else if (provider === 'anthropic') {
console.log(`[MCP -> Anthropic] command=${command}`);
const anthropicApiKey = apiKey || process.env.ANTHROPIC_API_KEY;
if (!anthropicApiKey) {
return res.status(400).json({ error: 'No Anthropic API key provided.' });
}
const isApi03 = anthropicApiKey.startsWith('sk-ant-api');
// Build payload
let url = 'https://api.anthropic.com/v1/messages';
let headers = {
'content-type': 'application/json'
};
if (isApi03) {
headers['x-api-key'] = anthropicApiKey;
headers['anthropic-version'] = '2023-06-01';
} else {
// legacy
url = 'https://api.anthropic.com/v1/complete';
headers['Authorization'] = `Bearer ${anthropicApiKey}`;
}
// Limit messages/history
let messagesArr = Array.isArray(req.body.messages)
? req.body.messages.slice(-HISTORY_LIMIT)
: [{ role: 'user', content: formattedPrompt }];
let body;
if (isApi03) {
const claudeMessages = messagesArr.map(m => ({ role: m.role, content: m.content }));
body = {
model: 'claude-3-opus-20240229',
max_tokens: 512,
messages: claudeMessages
};
} else {
// Convert messages to single prompt string
const promptStr = messagesArr.map(m => `${m.role === 'user' ? '' : 'Assistant:'} ${m.content}`).join('\n');
body = {
prompt: promptStr,
model: 'claude-v1',
max_tokens_to_sample: 512
};
}
const anthropicRes = await time('Anthropic API', () => fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body)
}));
if (!anthropicRes.ok) {
const err = await anthropicRes.text();
return res.status(400).json({ error: 'Anthropic API error', details: err });
}
const data = await anthropicRes.json();
let reply = '';
if (isApi03) {
// Claude 3 messages response
if (data.content && Array.isArray(data.content)) {
reply = data.content.map(part => part.text).join(' ');
}
} else {
// legacy complete response
reply = data.completion || data.text || '';
}
return res.json({
output: reply || 'No response from Anthropic.',
raw: data
});
} else if (provider === 'gemini' || provider === 'google') {
console.log(`[MCP -> Gemini] command=${command}`);
const geminiApiKey = apiKey || process.env.GEMINI_API_KEY;
if (!geminiApiKey) {
throw new Error('No Gemini API key provided');
}
// --- TRIM THE MESSAGES ARRAY TO LAST 10 ---
let messages = req.body.messages || [{ role: 'user', content: formattedPrompt }];
if (Array.isArray(messages) && messages.length > HISTORY_LIMIT) {
messages = messages.slice(-HISTORY_LIMIT);
}
const contents = Array.isArray(messages)
? messages.map(m => ({ role: m.role, parts: [{ text: m.content }] }))
: [{ role: 'user', parts: [{ text: messages[messages.length - 1]?.content || '' }] }];
const geminiRes = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiApiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents,
generationConfig: { maxOutputTokens: 256 }
})
}
);
if (!geminiRes.ok) {
const err = await geminiRes.text();
throw new Error(`Gemini API error: ${err}`);
}
const data = await geminiRes.json();
return res.json({
output: data.candidates?.[0]?.content?.parts?.[0]?.text || 'No response from Gemini.',
tokens_used: null,
model_version: 'gemini-2.0-flash',
provider: 'gemini',
raw_response: data,
});
} else if (provider === 'google_drive') {
// Refresh tokens if needed
await ensureGoogleTokens(req);
// We expect the OAuth bearer token to be provided in req.googleToken (set by requireAuth)
const googleToken = req.googleToken;
if (!googleToken) {
return res.status(401).json({ error: 'Missing Google OAuth token' });
}
const authClient = new google.auth.OAuth2();
authClient.setCredentials({ access_token: googleToken });
const drive = google.drive({ version: 'v3', auth: authClient });
try {
if (command === 'list-files') {
console.log('[MCP -> Google Drive] list-files');
const { data } = await drive.files.list({ pageSize: 100, fields: 'files(id, name, mimeType)' });
const files = (data.files || []).map(f => `${f.id} | ${f.name} (${f.mimeType})`).join('\n');
return res.json({ output: files || 'No files found.', provider: 'google_drive', command, raw_response: data });
} else if (command === 'search-files') {
const query = prompt || '';
console.log('[MCP -> Google Drive] search', query);
const { data } = await drive.files.list({ q: `name contains '${query.replace(/'/g, "\\'")}'`, pageSize: 50, fields: 'files(id, name, mimeType)' });
const out = (data.files || []).map(f => `${f.id} | ${f.name} (${f.mimeType})`).join('\n');
return res.json({ output: out || 'No matches.', provider: 'google_drive', command, raw_response: data });
} else if (command === 'download-file') {
const fileId = prompt.trim();
if (!fileId) throw new Error('Provide fileId in prompt');
const resp = await drive.files.get({ fileId, alt: 'media' }, { responseType: 'arraybuffer' });
const buffer = Buffer.from(resp.data);
const base64 = buffer.toString('base64');
return res.json({ output: base64, provider: 'google_drive', command, raw_response: { fileId, size: buffer.length } });
} else {
return res.status(400).json({ error: `Unsupported Google Drive command: ${command}` });
}
} catch (err) {
console.error('[MCP Server] Google Drive error:', err.message);
return res.status(400).json({ error: err.message });
}
} else if (provider === 'google_calendar') {
await ensureGoogleTokens(req);
const googleToken = req.googleToken;
if (!googleToken) {
return res.status(401).json({ error: 'Missing Google OAuth token' });
}
const authClient = new google.auth.OAuth2();
authClient.setCredentials({ access_token: googleToken });
const calendar = google.calendar({ version: 'v3', auth: authClient });
try {
if (command === 'list-events' || /list.*events/i.test(command || formattedPrompt)) {
const today = new Date();
const isoDate = today.toISOString().split('T')[0];
const timeMin = `${isoDate}T00:00:00Z`;
const timeMax = `${isoDate}T23:59:59Z`;
const { data } = await calendar.events.list({
calendarId: 'primary',
timeMin,
timeMax,
singleEvents: true,
orderBy: 'startTime'
});
const summary = (data.items || []).map(ev => {
const start = ev.start?.dateTime || ev.start?.date;
return `${start} | ${ev.summary}`;
}).join('\n');
return res.json({ output: summary || 'No events today.', provider: 'google_calendar', command: 'list-events', raw_response: data });
}
return res.status(400).json({ error: `Unsupported Google Calendar command: ${command}` });
} catch (err) {
console.error('[MCP Server] Google Calendar error:', err.message);
return res.status(400).json({ error: err.message });
}
} else if (provider === 'github') {
console.log(`[MCP -> GitHub] command=${command}`);
try {
// If no command specified (e.g. "Test token"), just validate the token
if (!command || command === 'validate' || command === 'test-token') {
if (!apiKey) throw new Error('GitHub API key (PAT) is required.');
const octokit = new Octokit({ auth: apiKey });
await octokit.request('GET /user');
return res.json({ success: true, output: 'Token is valid', provider: 'github' });
}
// Fallback to stored encrypted PAT if apiKey omitted
if (!apiKey) {
const creds = await daoGetCredentials('github');
apiKey = creds?.token ? decryptValue(creds.token) : '';
}
const result = await githubProviderPlugin.executeCommand({ command, prompt: formattedPrompt, apiKey });
return res.json(result);
} catch (err) {
console.error('[MCP Server] GitHub error:', err.message);
return res.status(400).json({ success: false, error: err.message });
}
} else if (provider === 'zapier') {
console.log(`[MCP -> Zapier] command=${command}`);
// Basic Zapier NLA support (https://nla.zapier.com/api/v1/)
const zapierKey = apiKey || req.body?.token || (req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.replace('Bearer ', '') : '');
if (!zapierKey) {
return res.status(401).json({ error: 'Missing Zapier API key' });
}
try {
// Currently support a single command: list-zaps (lists available AI Zaps for the user)
if (command === 'list-zaps' || /list zaps/i.test(command || formattedPrompt)) {
const resp = await fetch('https://nla.zapier.com/api/v1/ai_zaps/', {
headers: {
Authorization: `Bearer ${zapierKey}`,
Accept: 'application/json'
}
});
const isJson = resp.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await resp.json().catch(async () => ({ text: await resp.text() })) : await resp.text();
if (!resp.ok) {
return res.status(resp.status).json({ error: payload });
}
return res.json({
output: Array.isArray(payload) ? payload.map(z => `${z.id}: ${z.description || z.name}`).join('\n') : JSON.stringify(payload),
provider: 'zapier',
command: 'list-zaps',
raw_response: payload
});
}
// trigger zap <id>
if (/^trigger zap/i.test(command || formattedPrompt)) {
const match = (command || formattedPrompt).match(/trigger zap\s+(\S+)/i);
const zapId = match?.[1];
if (!zapId) {
return res.status(400).json({ error: 'Missing Zap ID' });
}
const execResp = await fetch(`https://nla.zapier.com/api/v1/ai_zaps/${zapId}/execute/`, {
method: 'POST',
headers: { Authorization: `Bearer ${zapierKey}` }
});
// Zapier returns JSON on success but may return HTML for 404/403
const isJson = execResp.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await execResp.json().catch(async () => ({ text: await execResp.text() })) : await execResp.text();
if (!execResp.ok) {
return res.status(execResp.status).json({ error: payload });
}
return res.status(execResp.status).json({
output: (payload.results || payload).description || payload.results || payload,
provider: 'zapier',
command: `trigger-zap-${zapId}`,
raw_response: payload
});
}
// If command not recognized yet
return res.status(400).json({ error: `Unsupported Zapier command: ${command}` });
} catch (err) {
console.error('[MCP Server] Zapier error:', err.message);
return res.status(400).json({ error: err.message });
}
} else if (provider === 'make_mcp_run') {
console.log('[MCP -> Make.com MCP run]');
const { zone, token, scenarioId } = req.body;
if (!zone || !token || !scenarioId) {
return res.status(400).json({ error: 'Missing required fields: zone, token, scenarioId' });
}
try {
const url = `https://${zone}/mcp/api/v1/u/${token}/execute/${scenarioId}`;
const r = await fetch(url, { headers: { Accept: 'text/event-stream' }, method: 'POST' });
return res.status(r.ok ? 202 : r.status).json({ ok: r.ok });
} catch (err) {
console.error('[MCP Server] Make.com execute error:', err.message);
return res.status(500).json({ ok: false, error: err.message });
}
} else if (provider === 'makecom') {
console.log(`[MCP -> Make.com] command=${command}`);
const makeToken = apiKey || req.body?.token || (req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.replace('Bearer ', '') : '');
if (!makeToken) {
return res.status(401).json({ error: 'Missing Make.com API token' });
}
const zone = req.body?.zone || 'eu1'; // default to EU zone
const makeBase = `https://${zone}.make.com/api/v2`;
try {
// LIST SCENARIOS
if (command === 'list-scenarios' || /list\s+scenarios/i.test(command || formattedPrompt)) {
const resp = await fetch(`${makeBase}/scenarios`, {
headers: { Authorization: makeToken, Accept: 'application/json' }
});
const payload = await resp.json();
if (!resp.ok) {
return res.status(resp.status).json({ error: payload });
}
const list = (payload.scenarios || []).map(s => `${s.id}: ${s.name} (active: ${s.isActive})`).join('\n');
return res.json({ output: list || 'No scenarios found.', provider: 'makecom', command: 'list-scenarios', raw_response: payload });
}
// RUN SCENARIO <id>
if (/run\s+scenario/i.test(command || formattedPrompt) || command === 'run') {
const match = (command || formattedPrompt).match(/run\s+scenario\s+(\d+)/i);
const scenarioId = match?.[1] || req.body?.scenarioId;
if (!scenarioId) {
return res.status(400).json({ error: 'Missing scenarioId' });
}
const endpoint = `${makeBase}/scenarios/${scenarioId}/run`;
const resp = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: makeToken,
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({ responsive: false })
});
const payload = await resp.json().catch(() => ({ text: 'Non-JSON response' }));
if (!resp.ok) {
return res.status(resp.status).json({ error: payload });
}
return res.json({ output: `Execution started (id: ${payload.executionId || 'n/a'})`, provider: 'makecom', command: `run-scenario-${scenarioId}`, raw_response: payload });
}
return res.status(400).json({ error: `Unsupported Make.com command: ${command}` });
} catch (err) {
console.error('[MCP Server] Make.com error:', err.message);
return res.status(400).json({ error: err.message });
}
} else if (provider === 'make_mcp_test') {
console.log(`[MCP -> Make.com MCP token test]`);
const { zone, token } = req.body;
if (!zone || !token) {
return res.status(400).json({ error: 'Missing required fields: zone and token' });
}
try {
const url = `https://${zone}/mcp/api/v1/u/${token}/sse`;
const r = await fetch(url, { headers: { Accept: 'text/event-stream' } });
return res.status(r.ok ? 200 : r.status).json({ ok: r.ok });
} catch (err) {
console.error('[MCP Server] Make.com test error:', err.message);
return res.status(500).json({ ok: false, error: err.message });
}
} else if (provider === 'make_webhook') {
// Trigger a classic Make.com webhook URL
const { url } = req.body;
if (!url || !/^https?:\/\/[^\s]*hook\./i.test(url)) {
return res.status(400).json({ error: 'Valid Make webhook URL required' });
}
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
const text = await r.text();
return res.status(r.status || 202).type('text').send(text || 'OK');
} catch (err) {
console.error('[MCP Server] Make.com webhook error:', err.message);
return res.status(500).json({ error: err.message || 'fetch failed' });
}
} else if (provider === 'gmail') {
console.log(`[MCP -> Gmail] command=${command}`);
// Ensure we have fresh tokens in session if possible
await ensureGoogleTokens(req);
const bearerToken = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.replace('Bearer ', '') : null;
const accessToken = apiKey || bearerToken || (req.session.googleTokens?.access_token);
const result = await gmailProviderPlugin.executeCommand({ command, prompt: formattedPrompt, apiKey: accessToken });
return res.json(result);
} else if (provider === 'slack') {
// fallback to stored token if apiKey omitted
if (!apiKey) {
const creds = await daoGetCredentials('slack');
apiKey = creds?.token ? decryptValue(creds.token) : '';
}
const result = await slackProvider.executeCommand({ command, params: req.body.params, apiKey });
return res.json(result);
} else if (provider === 'jira') {
let credentials;
if (req.body.host && req.body.email && apiKey) {
// Legacy basic auth (self-hosted Jira)
credentials = { host: req.body.host, email: req.body.email, apiToken: apiKey };
} else {
// Modern Cloud OAuth
const jiraAuth = await ensureJiraAccessToken();
if (!jiraAuth) return res.status(400).json({ error: 'Jira not connected' });
credentials = { accessToken: jiraAuth.accessToken, cloudId: jiraAuth.cloudId };
}
const result = await jiraProvider.executeCommand({ command, params: req.body.params, credentials });
return res.json(result);
} else if (provider === 'notion') {
if (!apiKey) return res.status(400).json({ ok:false, code:'AUTH_REQUIRED', message:'Notion not connected' });
try {
const result = await notionProvider.executeCommand({ command, params: req.body.params, apiKey });
return res.json(result);
} catch (err) {
console.error('[MCP/Notion]', err.message);
return res.status(400).json({ ok:false, code:'NOTION_ERROR', message: err.message });
}
} else if (provider === 'cursor') {
console.log(`[MCP -> Cursor] command=${command}`);
const result = await cursorProvider.executeCommand({ command, prompt: formattedPrompt, apiKey });
return res.json(result);
} else if (provider === '21st_dev') {
console.log(`[MCP -> 21st DEV] command=${command}`);
const result = await twentyFirstDevProvider.executeCommand({ command, prompt: formattedPrompt, apiKey });
return res.json(result);
} else if (provider === 'bolt') {
console.log(`[MCP -> Bolt] command=${command}`);
const result = await boltProvider.executeCommand({ command, prompt: formattedPrompt, apiKey });
return res.json(result);
} else {
return res.status(400).json({ error: 'Unsupported provider' });
}
} catch (err) {
console.error('[/api/command] unexpected', err);
res.status(500).json({ error: err.message });
}
});
app.post('/api/google/disconnect', (req, res) => {
req.session.googleTokens = undefined;
res.json({ disconnected: true });
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Backend running on port ${PORT}`);
// DEBUG – list every POST route on start-up
console.log(
'POST routes →',
app._router.stack
.filter(l => l.route && l.route.methods.post)
.map(l => l.route.path)
);
});
app.post('/api/github/trigger-action', async (req, res) => {
const octokit = getGitHubOctokitFromSession(req);
if (!octokit) return res.status(401).json({ error: 'Not authenticated with GitHub' });
const { owner, repo, workflow_id, ref, inputs } = req.body;
if (!owner || !repo || !workflow_id || !ref) {
return res.status(400).json({ error: 'Missing required fields (owner, repo, workflow_id, ref)' });
}
try {
// Trigger the workflow dispatch
await octokit.actions.createWorkflowDispatch({
owner,
repo,
workflow_id, // can be file name (e.g., main.yml) or workflow ID
ref, // branch or tag name
inputs: inputs || {}
});
// Optionally, list recent workflow runs for feedback
const runs = await octokit.actions.listWorkflowRuns({
owner,
repo,
workflow_id,
per_page: 1
});
const runUrl = runs.data.workflow_runs[0]?.html_url;
res.json({ success: true, runUrl, message: 'Workflow triggered!' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Zapier webhook endpoint
app.post('/api/webhook/zapier', express.json(), (req, res) => {
const { secret } = req.query;
// TODO: Validate secret against user config
console.log('Received Zapier webhook:', req.body);
res.json({ success: true });
});
// n8n webhook endpoint
app.post('/api/webhook/n8n', express.json(), (req, res) => {
const { secret } = req.query;
// TODO: Validate secret against user config
console.log('Received n8n webhook:', req.body);
res.json({ success: true });
});
// Example: Send to Zapier/n8n as a workflow step
async function sendToWebhook(url, payload, secret) {
try {
const headers = secret ? { 'X-MCP-Secret': secret } : {};
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(payload),
});
return await response.json();
} catch (err) {
console.error('Webhook error:', err);
return { error: err.message };
}
}
// --- SQLite Setup ---
let db;
(async () => {
db = await open({
filename: './mcp.sqlite',
driver: sqlite3.Database
});
await db.exec(`
CREATE TABLE IF NOT EXISTS User (
id INTEGER PRIMARY KEY AUTOINCREMENT,
githubId TEXT UNIQUE,
zapierUrl TEXT,
zapierSecret TEXT,
n8nUrl TEXT,
n8nSecret TEXT,
googleRefreshToken TEXT,
githubPAT TEXT,
slackToken TEXT
);
CREATE TABLE IF NOT EXISTS Webhook (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER,
name TEXT,
url TEXT,
type TEXT,
secret TEXT
);
CREATE TABLE IF NOT EXISTS WorkflowJob (
id TEXT PRIMARY KEY,
status TEXT,
created INTEGER,
updated INTEGER,
workflow_json TEXT,
context TEXT,
results_json TEXT
);
`);
})();
// List all webhooks for the user
app.get('/api/user/webhooks', async (req, res) => {
let userId = req.session.userId;
if (!userId) {
const user = await db.get('SELECT id FROM User LIMIT 1');
if (user) userId = user.id;
}
if (!userId) return res.status(404).json({ error: 'User not found' });
const webhooks = await db.all('SELECT * FROM Webhook WHERE userId = ?', userId);
res.json({ webhooks });
});
// Add a new webhook
app.post('/api/user/webhooks', express.json(), async (req, res) => {
const { name, url, type, secret } = req.body;
let userId = req.session.userId;
if (!userId) {
let user = await db.get('SELECT id FROM User LIMIT 1');
if (!user) {
const result = await db.run('INSERT INTO User (githubId) VALUES (?)', 'dummy');
userId = result.lastID;
} else {
userId = user.id;
}
}
await db.run('INSERT INTO Webhook (userId, name, url, type, secret) VALUES (?, ?, ?, ?, ?)', userId, name, url, type, secret);
res.json({ success: true });
});
// Delete a webhook
app.delete('/api/user/webhooks/:id', async (req, res) => {
const { id } = req.params;
await db.run('DELETE FROM Webhook WHERE id = ?', id);
res.json({ success: true });
});
// Provider API key validation endpoint
app.post('/api/validate/provider', express.json(), async (req, res) => {
const { provider, apiKey } = req.body;
try {
if (provider === 'openai') {
const OpenAI = (await import('openai')).default;
const openai = new OpenAI({ apiKey });
// Try a simple call
await openai.models.list();
return res.json({ success: true });
} else if (provider === 'anthropic') {
const { Anthropic } = await import('@anthropic-ai/sdk');
const anthropic = new Anthropic({ apiKey });
// Try a simple call
await anthropic.models.list();
return res.json({ success: true });
} else if (provider === 'gemini') {
const { GoogleGenerativeAI } = await import('@google/generative-ai');
const genAI = new GoogleGenerativeAI(apiKey);
// Try to list models
await genAI.listModels();
return res.json({ success: true });
} else if (provider === 'github') {
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: apiKey });
try {
// Try a simple authenticated call
await octokit.request('GET /user');
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, error: err.message });
}
} else {
return res.json({ success: false, error: 'Provider not supported for validation.' });
}
} catch (err) {
return res.json({ success: false, error: err.message });
}
});
// Webhook validation endpoint
app.post('/api/validate/webhook', express.json(), async (req, res) => {
const { url } = req.body;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test: true })
});
if (response.ok) {
return res.json({ success: true });
} else {
return res.json({ success: false, error: `Status ${response.status}` });
}
} catch (err) {
return res.json({ success: false, error: err.message });
}
});
// Health check endpoint
app.get('/api/health', (req, res) => res.json({ ok: true }));
// NEW: plain "/health" alias for convenience and spec compatibility
app.get('/health', (req, res) => {
res.json({ ok: true });
});
// NEW: "/commands" discovery endpoint (and /api/commands alias)
app.get(['/commands', '/api/commands'], (req, res) => {
// Surface server version for clients via header
res.set('MCP-Server-Version', MCP_SERVER_VERSION);
res.json(MCP_COMMANDS);
});
// NEW: "/schema" endpoint for autocomplete with caching
app.get('/schema', (req, res) => {
// 1-hour client cache so the Hub can poll every 60 min without redownloading if unchanged
res.set({
'Cache-Control': 'public, max-age=3600',
'MCP-Server-Version': MCP_SERVER_VERSION
});
res.json(MCP_COMMANDS);
});
// Helper to fetch single user row (id=1) or create if missing
async function getOrCreateUser() {
let user = await db.get('SELECT * FROM User LIMIT 1');
if (!user) {
await db.run('INSERT INTO User (githubId) VALUES (NULL)');
user = await db.get('SELECT * FROM User LIMIT 1');
}
return user;
}
// --- NEW: encryption helpers for API keys & tokens ---
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET || 'change-me'; // Fallback for local dev
function getAesKey() {
return crypto.createHash('sha256').update(ENCRYPTION_KEY).digest(); // 32-byte key
}
/**
* Encrypt arbitrary text with AES-256-GCM.
* Returns a value prefixed with "enc:" so we can detect encrypted payloads later.
*/
function encryptValue(str = '') {
if (!str) return str;
if (str.startsWith('enc:')) return str; // already encrypted
const iv = crypto.randomBytes(12); // 96-bit IV per NIST recommendation
const cipher = crypto.createCipheriv('aes-256-gcm', getAesKey(), iv);
const encrypted = Buffer.concat([cipher.update(str, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = Buffer.concat([iv, tag, encrypted]).toString('base64');
return `enc:${payload}`;
}
/**
* Decrypt previously encrypted value (created via encryptValue). If the value
* is not encrypted, it is returned as-is for backward compatibility.
*/
function decryptValue(str = '') {
if (!str || !str.startsWith('enc:')) return str;
try {
const buf = Buffer.from(str.slice(4), 'base64');
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const encrypted = buf.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', getAesKey(), iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString('utf8');
} catch {
// If decryption fails, return original string so we don't break prod data
return str;
}
}
// --- END encryption helpers ---
// --- GitHub PAT storage endpoints (updated with encryption) ---
app.get('/api/user/github-token', async (req, res) => {
try {
const creds = await daoGetCredentials('github');
const token = creds?.token ? decryptValue(creds.token) : '';
res.json({ token });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/user/github-token', express.json(), async (req, res) => {
const { token } = req.body;
if (!token) return res.status(400).json({ error: 'Missing token' });
try {
const encrypted = encryptValue(token);
await daoSaveCredentials('github', { token: encrypted });
res.json({ saved: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/user/github-token', async (req, res) => {
try {
await daoDeleteCredentials('github');
res.json({ deleted: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- Slack Bot token storage endpoints ---
app.get('/api/user/slack-token', async (req, res) => {
try {
const creds = await daoGetCredentials('slack');
const token = creds?.token ? decryptValue(creds.token) : '';
res.json({ token });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/user/slack-token', express.json(), async (req, res) => {
const { token } = req.body;
if (!token) return res.status(400).json({ error: 'Missing token' });
try {
await daoSaveCredentials('slack', { token: encryptValue(token) });
res.json({ saved: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/user/slack-token', async (req, res) => {
try {
await daoDeleteCredentials('slack');
res.json({ deleted: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// After Slack token routes, add unified credentials REST endpoints
app.get('/api/credentials/:providerId', async (req, res) => {
const { providerId } = req.params;
if (!providerId) return res.status(400).json({ error: 'Missing providerId' });
try {
const credentials = await daoGetCredentials(providerId);
const decrypted = decryptCredentialsObj(credentials);
return res.json({ credentials: decrypted });
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
app.post('/api/credentials/:providerId', express.json(), async (req, res) => {
const { providerId } = req.params;
if (!providerId) return res.status(400).json({ error: 'Missing providerId' });
const { credentials } = req.body || {};
if (!credentials || typeof credentials !== 'object') return res.status(400).json({ error: 'Missing credentials object' });
try {
const encrypted = encryptCredentialsObj(credentials);
await daoSaveCredentials(providerId, encrypted);
return res.json({ saved: true });
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
app.delete('/api/credentials/:providerId', async (req, res) => {
const { providerId } = req.params;
if (!providerId) return res.status(400).json({ error: 'Missing providerId' });
try {
await daoDeleteCredentials(providerId);
return res.json({ deleted: true });
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// MCP JSON-RPC endpoint — minimal support for initialize, tools/list, and tools/call
app.post('/mcp', async (req, res) => {
const rpc = req.body;
if (!rpc || typeof rpc !== 'object' || !rpc.method) {
return res.status(400).json({ error: 'Invalid JSON-RPC request' });
}
const { id } = rpc; // undefined for notifications
const sendResult = (result) => res.json({ jsonrpc: '2.0', id, result });
const sendError = (code, message) => res.json({ jsonrpc: '2.0', id, error: { code, message } });
// If this is a notification (no id), handle known notifications silently
if (id === undefined) {
if (rpc.method === 'notifications/initialized') {
console.log('[MCP] Client initialized');
return res.end(); // No response for notifications per JSON-RPC spec
}
// Unknown notifications are simply ignored
return res.end();
}
try {
// 1) Capability negotiation / handshake
if (rpc.method === 'initialize') {
const requestedVersion = rpc.params?.protocolVersion;
const supportedVersions = ['2025-03-26', '2024-11-05'];
const version = supportedVersions.includes(requestedVersion)
? requestedVersion
: '2025-03-26';
return sendResult({
protocolVersion: version,
serverInfo: {
name: 'mcpserver',
version: '0.1.0'
},
capabilities: {
logging: {},
prompts: { listChanged: true },
resources: { listChanged: true },
tools: { listChanged: true }
}
});
}
// 2) List available tools (one per command in MCP_COMMANDS)
if (rpc.method === 'tools/list') {
const tools = MCP_COMMANDS.map(cmd => ({
name: cmd.id,
description: cmd.description,
inputSchema: {
type: 'object',
properties: {
provider: { type: 'string', enum: cmd.providers },
prompt: { type: 'string' },
apiKey: { type: 'string' }
},
required: ['provider'],
additionalProperties: true
}
}));
return sendResult({ tools });
}
// 3) Execute a tool call by proxying to /api/command
if (rpc.method === 'tools/call') {
const { name, arguments: args } = rpc.params || {};
if (!name) return sendError(-32602, 'Missing tool name');
const provider = (args && args.provider) || 'openai';
const command = name; // tool name matches command id
const prompt = (args && args.prompt) || '';
const apiKey = args?.apiKey;
try {
const resp = await fetch(`http://localhost:${PORT}/api/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, provider, command, apiKey })
}).then(r => r.json());
if (resp.error) {
return sendResult({
content: [{ type: 'text', text: `Error: ${resp.error}` }],
isError: true
});
}
return sendResult({
content: [{ type: 'text', text: resp.output || '' }],
isError: false
});
} catch (err) {
return sendResult({
content: [{ type: 'text', text: `Internal error: ${err.message}` }],
isError: true
});
}
}
// Fallback for unknown methods
return sendError(-32601, `Unknown method ${rpc.method}`);
} catch (err) {
console.error('[MCP] Error:', err);
return sendError(-32603, err.message);
}
});
// ── MCP SSE stream ───────────────────────────────────────────────
app.get('/mcp', (req, res) => {
console.log('[MCP] Incoming SSE connection from', req.ip);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Allow any origin so Claude Desktop (which runs its own origin) can connect during local dev
res.setHeader('Access-Control-Allow-Origin', '*');
res.flushHeaders(); // send headers now
// Send initial comment to complete EventSource handshake immediately
res.write(': connected\n\n');
// keep-alive ping every 20 s
const ping = setInterval(() => {
res.write('event: ping\ndata: {}\n\n');
}, 20000);
req.on('close', () => {
clearInterval(ping);
console.log('[MCP] SSE client', req.ip, 'disconnected');
});
});
// --- simple timing helper ---
async function time(label, fn) {
const start = Date.now();
try {
const res = await fn();
const ms = Date.now() - start;
const status = res?.status ?? 'ok';
console.log(`[${label}] ${ms}ms status=${status}`);
return res;
} catch (err) {
console.log(`[${label}] ERROR after ${Date.now() - start}ms -> ${err.message}`);
throw err;
}
}
app.post('/api/validate-key', express.json(), async (req, res) => {
const { provider, apiKey } = req.body || {};
if (!provider || !apiKey) return res.status(400).json({ valid: false, error: 'Missing provider or apiKey' });
try {
if (provider === 'github') {
const octo = new Octokit({ auth: apiKey });
await octo.request('GET /user');
return res.json({ valid: true });
}
if (provider === 'openai') {
const resp = await fetch('https://api.openai.com/v1/models', {
headers: { Authorization: `Bearer ${apiKey}` }
});
return res.json({ valid: resp.ok });
}
if (provider === 'anthropic') {
const resp = await fetch('https://api.anthropic.com/v1/models', {
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json'
},
method: 'GET'
});
return res.json({ valid: resp.ok });
}
if (provider === 'gemini' || provider === 'google' ) {
const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
return res.json({ valid: resp.ok });
}
if (provider === 'cursor' || provider === '21st_dev' || provider === 'bolt') {
// TODO: Implement real validation once provider APIs are available.
// For now, treat any non-empty key as valid.
const valid = typeof apiKey === 'string' && apiKey.trim().length > 0;
return res.json({ valid });
}
// Default: unknown provider
return res.status(400).json({ valid: false, error: 'Provider not supported' });
} catch (err) {
return res.json({ valid: false, error: err.message });
}
});
// --- Google OAuth token refresh endpoint ---
app.post('/google/refresh', express.json(), async (req, res) => {
const { refresh_token } = req.body;
if (!refresh_token) return res.status(400).json({ error: 'Missing refresh_token' });
try {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token
});
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
if (!tokenRes.ok) {
const text = await tokenRes.text();
return res.status(401).json({ error: 'refresh_failed', details: text });
}
const json = await tokenRes.json(); // { access_token, expires_in, scope, token_type }
return res.json({ access_token: json.access_token, expires_in: json.expires_in });
} catch (err) {
console.error('Google refresh error', err);
return res.status(500).json({ error: err.message });
}
});
// alias for compatibility
app.post('/refresh', (req, res) => {
req.url = '/google/refresh';
// continue to existing route handler stack
app._router.handle(req, res);
});
// --- Status endpoints for front-end UI ---
app.get('/api/google/status', async (req, res) => {
try {
// Session token
if (req.session.googleTokens?.access_token) {
return res.json({ connected: true });
}
// DAO refresh token
const creds = await daoGetCredentials('google');
const hasRefresh = !!creds?.refreshToken;
return res.json({ connected: hasRefresh });
} catch (err) {
return res.status(500).json({ connected: false, error: err.message });
}
});
app.get('/api/github/status', async (req, res) => {
try {
const user = await getOrCreateUser();
const hasToken = !!user.githubPAT;
return res.json({ connected: hasToken });
} catch (err) {
return res.status(500).json({ connected: false, error: err.message });
}
});
// After existing /api/github/status endpoint
app.get('/api/:provider/status', async (req, res) => {
const providerId = canonicalProvider(req.params.provider);
try {
if (providerId === 'google') {
// session first
if (req.session.googleTokens?.access_token) {
return res.json({ connected: true });
}
const creds = await daoGetCredentials('google');
return res.json({ connected: !!creds?.refreshToken });
}
if (providerId === 'github' || providerId === 'slack' || providerId === 'notion') {
const creds = await daoGetCredentials(providerId);
return res.json({ connected: !!creds?.token || !!creds?.apiKey });
}
// ─── Jira status ─────────────────────────────────────────────
if (providerId === 'jira') {
const creds = await daoGetCredentials('jira');
return res.json({ connected: !!creds });
}
// default: check any credentials blob
const creds = await daoGetCredentials(providerId);
return res.json({ connected: !!creds });
} catch (err) {
return res.status(500).json({ connected: false, error: err.message });
}
});
// ── SSE Event Bus (mcp/events) ─────────────────────────────
const sseClients = new Set();
function broadcastEvent(type, payload = {}) {
const json = JSON.stringify(payload);
for (const res of sseClients) {
try {
res.write(`event: ${type}\n`);
res.write(`data: ${json}\n\n`);
} catch {
// client disconnected; will be pruned on 'close'
}
}
}
app.get('/mcp/events', (req, res) => {
console.log('[MCP] New /mcp/events connection from', req.ip);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
res.flushHeaders();
res.write(': connected\n\n');
sseClients.add(res);
const ping = setInterval(() => {
if (!res.writableEnded) {
res.write('event: ping\n');
res.write('data: {}\n\n');
}
}, 20000);
req.on('close', () => {
clearInterval(ping);
sseClients.delete(res);
console.log('[MCP] /mcp/events client disconnected', req.ip);
});
});
// ── Token Manager (auto-refresh) ──────────────────────────
function startTokenManager() {
const FIVE_MIN = 5 * 60 * 1000;
const intervalMs = 10 * 60 * 1000; // 10 min
setInterval(async () => {
try {
const creds = await daoGetCredentials('google');
if (!creds || !creds.refreshToken) return;
const expiresAt = creds.expiresAt || 0;
if (expiresAt - Date.now() > FIVE_MIN) return; // still fresh
const refreshToken = decryptValue(creds.refreshToken);
const oauth = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
'http://localhost:3001/auth/google/callback'
);
oauth.setCredentials({ refresh_token: refreshToken });
const { credentials } = await oauth.refreshAccessToken();
const newCreds = {
refreshToken: encryptValue(refreshToken),
accessToken: encryptValue(credentials.access_token),
expiresAt: credentials.expiry_date || (Date.now() + 3300000)
};
await daoSaveCredentials('google', newCreds);
broadcastEvent('token-refresh', { provider: 'google', refreshed: true });
console.log('[TokenManager] Google token refreshed');
} catch (err) {
console.error('[TokenManager] Google refresh failed:', err.message);
}
}, intervalMs);
}
startTokenManager();
// Provider alias map (canonicalizing IDs)
const PROVIDER_ALIASES = {
gmail: 'google',
google_drive: 'google',
google_calendar: 'google'
};
function canonicalProvider(id = '') {
return PROVIDER_ALIASES[id] || id;
}
// ── Global Error Handler ───────────────────────────────
// Centralized error handling so that uncaught errors in any route
// are logged consistently and a JSON response is returned.
app.use((err, req, res, next) => {
console.error('[Error]', err.stack || err);
if (res.headersSent) return next(err);
res.status(500).json({ error: err?.message || 'Internal server error' });
});
// Helper: encrypt all string properties in a plain object (shallow)
function encryptCredentialsObj(obj = {}) {
if (!obj || typeof obj !== 'object') return obj;
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string') {
out[k] = encryptValue(v);
} else if (Array.isArray(v)) {
out[k] = v.map(item => typeof item === 'string' ? encryptValue(item) : item);
} else {
out[k] = v; // leave non-strings untouched (or nested objs as-is)
}
}
return out;
}
function decryptCredentialsObj(obj = {}) {
if (!obj || typeof obj !== 'object') return obj;
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string') {
out[k] = decryptValue(v);
} else if (Array.isArray(v)) {
out[k] = v.map(item => typeof item === 'string' ? decryptValue(item) : item);
} else {
out[k] = v;
}
}
return out;
}
// --- Notion proxy to bypass CORS (server-side pass-through) ---
const NOTION_API_BASE = 'https://api.notion.com/v1';
const NOTION_VERSION = '2022-06-28';
app.post(['/proxy/notion','/api/proxy/notion'], express.json({ limit: '10mb' }), async (req, res) => {
try {
const { path = '', method = 'GET', body = null, apiKey } = req.body || {};
if (!path) return res.status(400).json({ ok:false, code:'VALIDATION', message:'Missing "path" body param' });
// Resolve access token (Bearer)
let token = apiKey;
if (!token) {
const stored = await daoGetCredentials('notion');
if (stored) {
const dec = decryptCredentialsObj(stored);
token = dec.access_token;
}
}
if (!token) return res.status(401).json({ ok:false, code:'AUTH_REQUIRED', message:'Notion integration not connected' });
const upstreamUrl = `${NOTION_API_BASE}/${path.replace(/^\/+/, '')}`;
const headers = {
Authorization: `Bearer ${token}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json'
};
const upstreamResp = await fetch(upstreamUrl, {
method: method.toUpperCase(),
headers,
body: method.toUpperCase() === 'GET' ? undefined : JSON.stringify(body || {})
});
const isJson = upstreamResp.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await upstreamResp.json().catch(async () => ({ raw: await upstreamResp.text() })) : await upstreamResp.text();
res.status(upstreamResp.status).json(payload);
} catch (err) {
console.error('[Proxy/Notion] error', err);
res.status(500).json({ ok:false, code:'SERVER', message:err.message });
}
});
// --- Jira proxy to bypass CORS (server-side pass-through) ---
const JIRA_API_BASE = 'https://api.atlassian.com/ex/jira';
app.post(['/proxy/jira','/api/proxy/jira'], express.json({ limit: '10mb' }), async (req, res) => {
try {
const { path = '', method = 'GET', body = null, accessToken, cloudId } = req.body || {};
if (!path) return res.status(400).json({ ok:false, code:'VALIDATION', message:'Missing "path" body param' });
// Resolve Jira access token & cloudId
let token = accessToken;
let cId = cloudId;
if (!token || !cId) {
const auth = await ensureJiraAccessToken();
if (!auth) return res.status(401).json({ ok:false, code:'AUTH_REQUIRED', message:'Jira integration not connected' });
token = auth.accessToken;
cId = auth.cloudId;
}
const upstreamUrl = `${JIRA_API_BASE}/${cId}/rest/api/3/${path.replace(/^\/+/,'')}`;
const headers = {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'Content-Type': 'application/json'
};
const upstreamResp = await fetch(upstreamUrl, {
method: method.toUpperCase(),
headers,
body: method.toUpperCase() === 'GET' ? undefined : JSON.stringify(body || {})
});
const isJson = upstreamResp.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await upstreamResp.json().catch(async () => ({ raw: await upstreamResp.text() })) : await upstreamResp.text();
res.status(upstreamResp.status).json(payload);
} catch (err) {
console.error('[Proxy/Jira] error', err);
res.status(500).json({ ok:false, code:'SERVER', message: err.message });
}
});