1189 lines
44 KiB
JavaScript
1189 lines
44 KiB
JavaScript
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
|
import jwt from 'jsonwebtoken';
|
|
import { z } from 'zod';
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
import env from '../../config/env.js';
|
|
import * as mcpDb from '../../db/mcp.js';
|
|
import * as googleAuthDb from '../../db/googleAuth.js';
|
|
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from '../../tools/prompts.js';
|
|
import { createSearchArticlesTool } from '../../tools/search-articles.js';
|
|
import { createSubagentTool } from '../../tools/subagent.js';
|
|
import { resolveDefaultModel } from '../../tools/llm-key.js';
|
|
|
|
const MAX_RESPONSE_CHARS = 20_000;
|
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
|
|
function getBaseUrl(req) {
|
|
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
|
const host = req.get('host') || `127.0.0.1:${env.port}`;
|
|
return `${proto}://${host}`;
|
|
}
|
|
|
|
function getFrontendUrl(req) {
|
|
if (process.env.FRONTEND_URL) {
|
|
return process.env.FRONTEND_URL.replace(/\/$/, '');
|
|
}
|
|
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
|
const host = req.get('host') || `127.0.0.1:${env.port}`;
|
|
const hostname = host.split(':')[0];
|
|
return `${proto}://${hostname}:4209`;
|
|
}
|
|
|
|
function mcpErrorStatus(error) {
|
|
if (!error) return 500;
|
|
if (error.message === 'No company selected') return 400;
|
|
if (error.message === 'Not authorized' || error.message === 'User is no longer authorized') return 403;
|
|
if (error.message === 'Unauthorized') return 401;
|
|
if (error.message?.endsWith(' not found') || error.message?.includes('not found')) return 404;
|
|
if (error.message?.includes('required') || error.message?.includes('Invalid')) return 400;
|
|
return 500;
|
|
}
|
|
|
|
/**
|
|
* Checks if a candidate redirect_uri matches any registered redirect URI.
|
|
* Per RFC 8252 Section 7.3 (OAuth 2.0 for Native Apps):
|
|
* "The authorization server MUST allow any port for loopback IP redirect URIs."
|
|
* @param {string} candidateUri
|
|
* @param {Array<string>|string} registeredUris
|
|
* @returns {boolean}
|
|
*/
|
|
function isRedirectUriAllowed(candidateUri, registeredUris) {
|
|
if (!candidateUri || !registeredUris) return false;
|
|
const uriList = Array.isArray(registeredUris) ? registeredUris : [registeredUris];
|
|
if (uriList.includes(candidateUri)) return true;
|
|
|
|
try {
|
|
const candidate = new URL(candidateUri);
|
|
const isCandidateLoopback = candidate.hostname === '127.0.0.1' || candidate.hostname === 'localhost';
|
|
|
|
for (const reg of uriList) {
|
|
try {
|
|
const registered = new URL(reg);
|
|
const normCandidatePath = candidate.pathname.replace(/\/$/, '');
|
|
const normRegisteredPath = registered.pathname.replace(/\/$/, '');
|
|
|
|
if (
|
|
candidate.origin === registered.origin &&
|
|
normCandidatePath === normRegisteredPath
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const isRegisteredLoopback = registered.hostname === '127.0.0.1' || registered.hostname === 'localhost';
|
|
if (
|
|
isCandidateLoopback &&
|
|
isRegisteredLoopback &&
|
|
candidate.protocol === registered.protocol &&
|
|
normCandidatePath === normRegisteredPath
|
|
) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
// Ignore malformed registered URI
|
|
}
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const tokenVerifier = {
|
|
verifyAccessToken: async (token) => {
|
|
let decoded;
|
|
try {
|
|
decoded = jwt.verify(token, env.jwtSecret);
|
|
} catch (err) {
|
|
throw new Error(`Invalid access token: ${err.message}`);
|
|
}
|
|
|
|
const userId = Number(decoded.id || decoded.sub);
|
|
if (!Number.isInteger(userId) || userId <= 0) {
|
|
throw new Error('Invalid token subject');
|
|
}
|
|
|
|
const user = await googleAuthDb.getById(userId);
|
|
if (!user) {
|
|
throw new Error('User is no longer authorized');
|
|
}
|
|
|
|
const scopes = typeof decoded.scope === 'string'
|
|
? decoded.scope.split(' ')
|
|
: Array.isArray(decoded.scope)
|
|
? decoded.scope
|
|
: ['mcp:tools'];
|
|
|
|
return {
|
|
token,
|
|
clientId: decoded.client_id || 'unknown',
|
|
scopes,
|
|
expiresAt: decoded.exp,
|
|
resource: decoded.aud,
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
selectedCompanyId: user.selectedCompanyId,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
function createDashbackMcpServer({ user }) {
|
|
const server = new McpServer({
|
|
name: 'dashback-mcp',
|
|
version: '1.0.0',
|
|
});
|
|
|
|
server.registerTool(
|
|
'dashback_request',
|
|
{
|
|
title: 'Dashback API Request',
|
|
description: TOOL_DESCRIPTIONS.dashback_request || 'Perform HTTP requests against the Dashback API',
|
|
inputSchema: {
|
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.method),
|
|
path: z.string().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.path),
|
|
query: z.record(z.string(), z.any()).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.query),
|
|
body: z.union([z.string(), z.record(z.string(), z.any())]).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.body),
|
|
note: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.note),
|
|
},
|
|
},
|
|
async ({ method = 'GET', path, query = null, body = null, note }) => {
|
|
if (!METHODS.includes(method)) throw new Error(`Unsupported method: ${method}`);
|
|
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) {
|
|
throw new Error('path must start with a single "/"');
|
|
}
|
|
|
|
const token = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '5m' });
|
|
const headers = { Authorization: `Bearer ${token}` };
|
|
const init = { method, headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) };
|
|
if (body != null && method !== 'GET' && method !== 'DELETE') {
|
|
headers['Content-Type'] = 'application/json';
|
|
init.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
}
|
|
|
|
const qs = query ? `?${new URLSearchParams(query).toString()}` : '';
|
|
const url = `${path.startsWith('/docs') || path.startsWith('/api/') ? path : `/api${path}`}${qs}`;
|
|
console.log(`[mcp] Tool invoke dashback_request: ${method} ${url}`);
|
|
const res = await fetch(`http://127.0.0.1:${env.port}${url}`, init);
|
|
let text = await res.text();
|
|
if (text.length > MAX_RESPONSE_CHARS) {
|
|
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
|
|
}
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ status: res.status, body: text }) }],
|
|
};
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'dash-subagent',
|
|
{
|
|
title: 'Dashback Subagent',
|
|
description: TOOL_DESCRIPTIONS.subagent || 'Delegate a task to an isolated subagent with access to Dashback tools and VFS.',
|
|
inputSchema: {
|
|
task: z.string().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.task || 'Specific instructions and goal for the subagent.'),
|
|
skills: z.array(z.string()).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.skills || 'Array of SKILL names or slugs to inject into the subagent prompt.'),
|
|
conversation_id: z.number().int().positive().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.conversation_id || 'Existing subagent conversation ID to follow up on, or null to create a new one.'),
|
|
max_iterations: z.number().int().min(10).max(50).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.max_iterations || 'Safety bound on tool loops (min 10, default 20, max 50).'),
|
|
model: z.string().nullable().optional().describe('Optional OpenRouter model override. Defaults to company default model.'),
|
|
note: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.note || 'Status message shown during execution.'),
|
|
},
|
|
},
|
|
async ({ task, skills = null, conversation_id = null, max_iterations = null, model = null, note = null }, extra = {}) => {
|
|
const freshUser = await googleAuthDb.getById(user.id);
|
|
const companyId = freshUser?.selectedCompanyId || user.selectedCompanyId;
|
|
if (!companyId) throw new Error('No company selected');
|
|
|
|
const parentModel = model || (await resolveDefaultModel(user.id));
|
|
const tool = createSubagentTool({
|
|
googleAuthId: user.id,
|
|
companyId,
|
|
conversationId: null,
|
|
parentModel,
|
|
depth: 0,
|
|
});
|
|
|
|
console.log(`[mcp] Tool invoke dash-subagent: task="${task?.slice(0, 100)}"`);
|
|
const signals = [AbortSignal.timeout(300_000)];
|
|
if (extra?.signal) signals.push(extra.signal);
|
|
const signal = AbortSignal.any(signals);
|
|
|
|
const result = await tool.invoke(
|
|
{ task, skills, conversation_id, max_iterations, note },
|
|
{ signal }
|
|
);
|
|
|
|
let text = JSON.stringify(result, null, 2);
|
|
if (text.length > MAX_RESPONSE_CHARS) {
|
|
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
|
|
}
|
|
return {
|
|
content: [{ type: 'text', text }],
|
|
};
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'search-articles',
|
|
{
|
|
title: 'Search Articles',
|
|
description: TOOL_DESCRIPTIONS.search_articles || 'Search articles by name, brand, EAN, SKU, or supplier SKU (AND logic, ordered by name).',
|
|
inputSchema: {
|
|
searchquery: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.searchquery || 'Keywords matching name, brand, EAN, SKU, or supplier SKU (AND logic).'),
|
|
excludewords: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.excludewords || 'Keywords to exclude (AND logic).'),
|
|
offset: z.number().int().min(0).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.offset || 'Pagination offset (default 0).'),
|
|
limit: z.number().int().min(1).max(20).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.limit || 'Window size 1-20 (default 3).'),
|
|
},
|
|
},
|
|
async ({ searchquery = '', excludewords = '', offset = 0, limit = 3 } = {}) => {
|
|
const freshUser = await googleAuthDb.getById(user.id);
|
|
const companyId = freshUser?.selectedCompanyId || user.selectedCompanyId;
|
|
if (!companyId) throw new Error('No company selected');
|
|
|
|
console.log(`[mcp] Tool invoke search-articles: query="${searchquery ?? ''}" exclude="${excludewords ?? ''}" offset=${offset} limit=${limit}`);
|
|
const tool = createSearchArticlesTool({ googleAuthId: user.id, companyId });
|
|
const result = await tool.invoke({
|
|
searchquery: searchquery ?? '',
|
|
excludewords: excludewords ?? '',
|
|
offset: offset ?? 0,
|
|
limit: limit ?? 3,
|
|
});
|
|
let text = JSON.stringify(result, null, 2);
|
|
if (text.length > MAX_RESPONSE_CHARS) {
|
|
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
|
|
}
|
|
return {
|
|
content: [{ type: 'text', text }],
|
|
};
|
|
}
|
|
);
|
|
|
|
return server;
|
|
}
|
|
|
|
const transports = {};
|
|
|
|
function mcpAuthMiddleware(req, res, next) {
|
|
const baseUrl = getBaseUrl(req);
|
|
const middleware = requireBearerAuth({
|
|
verifier: tokenVerifier,
|
|
requiredScopes: [],
|
|
resourceMetadataUrl: `${baseUrl}/.well-known/oauth-protected-resource`,
|
|
});
|
|
return middleware(req, res, next);
|
|
}
|
|
|
|
/**
|
|
* Registers MCP server and OAuth 2.1 endpoints.
|
|
* @param {import('express').Application} app
|
|
* @param {string} [_basePath='']
|
|
*/
|
|
export function register(app, basePath = '') {
|
|
/**
|
|
* @openapi
|
|
* /.well-known/oauth-protected-resource:
|
|
* get:
|
|
* tags: [MCP]
|
|
* summary: OAuth 2.0 Protected Resource Metadata (RFC 9728)
|
|
* description: Returns protected resource metadata discovering the authorization server for the MCP endpoint.
|
|
* security: []
|
|
* responses:
|
|
* 200:
|
|
* description: Protected Resource Metadata
|
|
*/
|
|
const handleProtectedResourceMetadata = (req, res) => {
|
|
const baseUrl = getBaseUrl(req);
|
|
res.json({
|
|
resource: `${baseUrl}/mcp`,
|
|
authorization_servers: [baseUrl],
|
|
scopes_supported: ['mcp:tools'],
|
|
resource_name: 'Dashback MCP Server',
|
|
});
|
|
};
|
|
app.get('/.well-known/oauth-protected-resource', handleProtectedResourceMetadata);
|
|
app.get('/.well-known/oauth-protected-resource/mcp', handleProtectedResourceMetadata);
|
|
|
|
/**
|
|
* @openapi
|
|
* /.well-known/oauth-authorization-server:
|
|
* get:
|
|
* tags: [MCP]
|
|
* summary: OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
|
* description: Returns OAuth 2.0 authorization server endpoints and capabilities.
|
|
* security: []
|
|
* responses:
|
|
* 200:
|
|
* description: Authorization Server Metadata
|
|
*/
|
|
const handleAuthServerMetadata = (req, res) => {
|
|
const baseUrl = getBaseUrl(req);
|
|
const frontendUrl = getFrontendUrl(req);
|
|
res.json({
|
|
issuer: baseUrl,
|
|
authorization_endpoint: `${frontendUrl}/oauth/authorize`,
|
|
token_endpoint: `${baseUrl}/oauth/token`,
|
|
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
introspection_endpoint: `${baseUrl}/oauth/introspect`,
|
|
response_types_supported: ['code'],
|
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
code_challenge_methods_supported: ['S256'],
|
|
scopes_supported: ['mcp:tools'],
|
|
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
|
|
});
|
|
};
|
|
app.get('/.well-known/oauth-authorization-server', handleAuthServerMetadata);
|
|
app.get('/.well-known/openid-configuration', handleAuthServerMetadata);
|
|
|
|
/**
|
|
* @openapi
|
|
* /oauth/register:
|
|
* post:
|
|
* tags: [MCP]
|
|
* summary: Dynamic Client Registration (RFC 7591)
|
|
* description: Registers a new OAuth MCP client dynamically.
|
|
* security: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [redirect_uris]
|
|
* properties:
|
|
* client_name:
|
|
* type: string
|
|
* redirect_uris:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* responses:
|
|
* 201:
|
|
* description: Registered client credentials
|
|
*/
|
|
app.post('/oauth/register', async (req, res) => {
|
|
try {
|
|
const body = req.body || {};
|
|
const clientName = body.client_name || 'MCP Client';
|
|
const redirectUris = body.redirect_uris;
|
|
|
|
if (!redirectUris || (!Array.isArray(redirectUris) && typeof redirectUris !== 'string')) {
|
|
return res.status(400).json({
|
|
error: 'invalid_client_metadata',
|
|
error_description: 'redirect_uris is required and must be an array or string',
|
|
});
|
|
}
|
|
|
|
const clientId = randomUUID();
|
|
const clientSecret = randomBytes(32).toString('hex');
|
|
const grantTypes = body.grant_types || ['authorization_code', 'refresh_token'];
|
|
const responseTypes = body.response_types || ['code'];
|
|
const scope = body.scope || 'mcp:tools';
|
|
|
|
const record = await mcpDb.registerClient({
|
|
clientId,
|
|
clientSecret,
|
|
clientName,
|
|
redirectUris,
|
|
grantTypes,
|
|
responseTypes,
|
|
scope,
|
|
});
|
|
|
|
res.status(201).json({
|
|
client_id: record.clientId,
|
|
client_secret: record.clientSecret,
|
|
client_name: record.clientName,
|
|
redirect_uris: Array.isArray(redirectUris) ? redirectUris : [redirectUris],
|
|
grant_types: Array.isArray(grantTypes) ? grantTypes : [grantTypes],
|
|
response_types: Array.isArray(responseTypes) ? responseTypes : [responseTypes],
|
|
scope: record.scope,
|
|
});
|
|
} catch (err) {
|
|
console.error('[oauth] Client registration error:', err);
|
|
res.status(500).json({ error: 'server_error', error_description: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /oauth/authorize:
|
|
* get:
|
|
* tags: [MCP]
|
|
* summary: OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636)
|
|
* description: Redirects browser to frontend authorization consent page.
|
|
* security: []
|
|
* parameters:
|
|
* - in: query
|
|
* name: client_id
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: redirect_uri
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: response_type
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: code_challenge
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: code_challenge_method
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: state
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 302:
|
|
* description: Redirects to frontend /oauth/authorize
|
|
*/
|
|
app.get('/oauth/authorize', async (req, res) => {
|
|
const frontendUrl = getFrontendUrl(req);
|
|
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
|
return res.redirect(`${frontendUrl}/oauth/authorize${qs}`);
|
|
});
|
|
|
|
app.post('/oauth/authorize', async (req, res) => {
|
|
const {
|
|
client_id,
|
|
redirect_uri,
|
|
code_challenge,
|
|
code_challenge_method = 'S256',
|
|
state = '',
|
|
scope = 'mcp:tools',
|
|
action,
|
|
email,
|
|
} = req.body;
|
|
|
|
const redirectUrl = new URL(redirect_uri);
|
|
|
|
if (action === 'deny') {
|
|
redirectUrl.searchParams.set('error', 'access_denied');
|
|
if (state) redirectUrl.searchParams.set('state', state);
|
|
return res.redirect(redirectUrl.toString());
|
|
}
|
|
|
|
let userId = req.user?.id;
|
|
if (!userId && email) {
|
|
const user = await googleAuthDb.getAllowedEmail(email.trim());
|
|
if (!user) {
|
|
return res.status(403).send('Email is not authorized in Dashback');
|
|
}
|
|
userId = user.id;
|
|
}
|
|
|
|
if (!userId) {
|
|
return res.status(401).send('Not authenticated');
|
|
}
|
|
|
|
const code = randomBytes(32).toString('hex');
|
|
await mcpDb.createAuthCode({
|
|
code,
|
|
clientId: client_id,
|
|
googleAuthId: userId,
|
|
redirectUri: redirect_uri,
|
|
codeChallenge: code_challenge,
|
|
codeChallengeMethod: code_challenge_method,
|
|
scope: scope || 'mcp:tools',
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
|
|
});
|
|
|
|
redirectUrl.searchParams.set('code', code);
|
|
if (state) redirectUrl.searchParams.set('state', state);
|
|
return res.redirect(redirectUrl.toString());
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /oauth/token:
|
|
* post:
|
|
* tags: [MCP]
|
|
* summary: OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636)
|
|
* description: Exchanges authorization code with PKCE for an access token, or refreshes an expired token.
|
|
* security: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/x-www-form-urlencoded:
|
|
* schema:
|
|
* type: object
|
|
* required: [grant_type, client_id]
|
|
* properties:
|
|
* grant_type:
|
|
* type: string
|
|
* enum: [authorization_code, refresh_token]
|
|
* code:
|
|
* type: string
|
|
* redirect_uri:
|
|
* type: string
|
|
* client_id:
|
|
* type: string
|
|
* code_verifier:
|
|
* type: string
|
|
* refresh_token:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Issued access token and refresh token
|
|
*/
|
|
app.post('/oauth/token', async (req, res) => {
|
|
try {
|
|
const body = req.body || {};
|
|
const grantType = body.grant_type;
|
|
const clientId = body.client_id;
|
|
const baseUrl = getBaseUrl(req);
|
|
|
|
if (grantType === 'authorization_code') {
|
|
const { code, redirect_uri, code_verifier } = body;
|
|
if (!code || !redirect_uri || !code_verifier || !clientId) {
|
|
return res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'Missing code, redirect_uri, client_id, or code_verifier',
|
|
});
|
|
}
|
|
|
|
const authCode = await mcpDb.getAuthCode(code);
|
|
if (!authCode || authCode.used || new Date(authCode.expiresAt) < new Date()) {
|
|
return res.status(400).json({
|
|
error: 'invalid_grant',
|
|
error_description: 'Authorization code is invalid or has expired',
|
|
});
|
|
}
|
|
|
|
if (authCode.clientId !== clientId) {
|
|
return res.status(400).json({
|
|
error: 'invalid_grant',
|
|
error_description: 'Client ID does not match authorization code',
|
|
});
|
|
}
|
|
|
|
if (!isRedirectUriAllowed(redirect_uri, [authCode.redirectUri])) {
|
|
return res.status(400).json({
|
|
error: 'invalid_grant',
|
|
error_description: 'Redirect URI does not match authorization code',
|
|
});
|
|
}
|
|
|
|
// PKCE S256 verification
|
|
const calculatedChallenge = createHash('sha256').update(code_verifier).digest('base64url');
|
|
if (calculatedChallenge !== authCode.codeChallenge) {
|
|
return res.status(400).json({
|
|
error: 'invalid_grant',
|
|
error_description: 'PKCE verification failed',
|
|
});
|
|
}
|
|
|
|
await mcpDb.consumeAuthCode(code);
|
|
|
|
const scope = authCode.scope || 'mcp:tools';
|
|
const accessToken = jwt.sign(
|
|
{
|
|
sub: String(authCode.googleAuthId),
|
|
id: authCode.googleAuthId,
|
|
client_id: clientId,
|
|
scope,
|
|
aud: `${baseUrl}/mcp`,
|
|
iss: baseUrl,
|
|
},
|
|
env.jwtSecret,
|
|
{ expiresIn: '1h' }
|
|
);
|
|
|
|
const refreshToken = randomBytes(32).toString('hex');
|
|
await mcpDb.createRefreshToken({
|
|
refreshToken,
|
|
clientId,
|
|
googleAuthId: authCode.googleAuthId,
|
|
scope,
|
|
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
|
});
|
|
|
|
return res.json({
|
|
access_token: accessToken,
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
refresh_token: refreshToken,
|
|
scope,
|
|
});
|
|
} else if (grantType === 'refresh_token') {
|
|
const { refresh_token } = body;
|
|
if (!refresh_token || !clientId) {
|
|
return res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'Missing refresh_token or client_id',
|
|
});
|
|
}
|
|
|
|
const tokenRow = await mcpDb.getRefreshToken(refresh_token);
|
|
if (!tokenRow || tokenRow.revoked || new Date(tokenRow.expiresAt) < new Date()) {
|
|
return res.status(400).json({
|
|
error: 'invalid_grant',
|
|
error_description: 'Refresh token is invalid or expired',
|
|
});
|
|
}
|
|
|
|
if (tokenRow.clientId !== clientId) {
|
|
return res.status(400).json({
|
|
error: 'invalid_grant',
|
|
error_description: 'Client ID does not match refresh token',
|
|
});
|
|
}
|
|
|
|
const scope = tokenRow.scope || 'mcp:tools';
|
|
const accessToken = jwt.sign(
|
|
{
|
|
sub: String(tokenRow.googleAuthId),
|
|
id: tokenRow.googleAuthId,
|
|
client_id: clientId,
|
|
scope,
|
|
aud: `${baseUrl}/mcp`,
|
|
iss: baseUrl,
|
|
},
|
|
env.jwtSecret,
|
|
{ expiresIn: '1h' }
|
|
);
|
|
|
|
return res.json({
|
|
access_token: accessToken,
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
refresh_token,
|
|
scope,
|
|
});
|
|
} else {
|
|
return res.status(400).json({
|
|
error: 'unsupported_grant_type',
|
|
error_description: `Grant type ${grantType} is not supported`,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('[oauth] Token endpoint error:', err);
|
|
res.status(500).json({ error: 'server_error', error_description: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /oauth/introspect:
|
|
* post:
|
|
* tags: [MCP]
|
|
* summary: OAuth 2.0 Token Introspection (RFC 7662)
|
|
* description: Verifies the validity of an issued access token.
|
|
* security: []
|
|
* responses:
|
|
* 200:
|
|
* description: Token status and metadata
|
|
*/
|
|
app.post('/oauth/introspect', async (req, res) => {
|
|
const { token } = req.body || {};
|
|
if (!token) {
|
|
return res.json({ active: false });
|
|
}
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, env.jwtSecret);
|
|
res.json({
|
|
active: true,
|
|
client_id: decoded.client_id,
|
|
sub: decoded.sub,
|
|
scope: decoded.scope,
|
|
exp: decoded.exp,
|
|
aud: decoded.aud,
|
|
iss: decoded.iss,
|
|
});
|
|
} catch {
|
|
res.json({ active: false });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /mcp:
|
|
* post:
|
|
* tags: [MCP]
|
|
* summary: Model Context Protocol (MCP) Streamable HTTP endpoint
|
|
* description: Handles MCP JSON-RPC protocol requests and streamable sessions. Protected by Bearer token authorization.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: MCP JSON-RPC response
|
|
* 401:
|
|
* description: Missing or invalid authorization token
|
|
*/
|
|
app.post('/mcp', mcpAuthMiddleware, async (req, res) => {
|
|
const sessionId = req.headers['mcp-session-id'];
|
|
let transport;
|
|
|
|
if (sessionId && transports[sessionId]) {
|
|
transport = transports[sessionId].transport;
|
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => randomUUID(),
|
|
enableJsonResponse: true,
|
|
onsessioninitialized: (newSessionId) => {
|
|
transports[newSessionId] = {
|
|
transport,
|
|
user: req.auth?.user,
|
|
};
|
|
},
|
|
});
|
|
|
|
transport.onclose = () => {
|
|
if (transport.sessionId) {
|
|
delete transports[transport.sessionId];
|
|
}
|
|
};
|
|
|
|
const server = createDashbackMcpServer({ user: req.auth.user });
|
|
await server.connect(transport);
|
|
} else {
|
|
return res.status(400).json({
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32000,
|
|
message: 'Bad Request: No valid session ID provided',
|
|
},
|
|
id: null,
|
|
});
|
|
}
|
|
|
|
await transport.handleRequest(req, res, req.body);
|
|
});
|
|
|
|
const handleSessionRequest = async (req, res) => {
|
|
const sessionId = req.headers['mcp-session-id'];
|
|
if (!sessionId || !transports[sessionId]) {
|
|
return res.status(400).send('Invalid or missing session ID');
|
|
}
|
|
const { transport } = transports[sessionId];
|
|
await transport.handleRequest(req, res);
|
|
};
|
|
|
|
app.get('/mcp', mcpAuthMiddleware, handleSessionRequest);
|
|
app.delete('/mcp', mcpAuthMiddleware, handleSessionRequest);
|
|
|
|
/**
|
|
* @openapi
|
|
* /mcp/authorize:
|
|
* get:
|
|
* tags: [MCP]
|
|
* summary: Get MCP authorization prompt details
|
|
* description: Fetches client information and current user context for the frontend MCP authorization consent screen.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* parameters:
|
|
* - in: query
|
|
* name: client_id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: redirect_uri
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: response_type
|
|
* schema:
|
|
* type: string
|
|
* enum: [code]
|
|
* - in: query
|
|
* name: code_challenge
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: code_challenge_method
|
|
* schema:
|
|
* type: string
|
|
* enum: [S256]
|
|
* - in: query
|
|
* name: state
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: scope
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Client and user consent metadata
|
|
* 400:
|
|
* description: Invalid request or unregistered redirect_uri
|
|
* 401:
|
|
* description: User not authenticated
|
|
* 404:
|
|
* description: Client not found
|
|
*/
|
|
app.get(`${basePath}/mcp/authorize`, async (req, res) => {
|
|
try {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
const {
|
|
client_id,
|
|
redirect_uri,
|
|
response_type = 'code',
|
|
code_challenge,
|
|
code_challenge_method = 'S256',
|
|
state = '',
|
|
scope = 'mcp:tools',
|
|
} = req.query;
|
|
|
|
if (!client_id) {
|
|
return res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'Missing client_id',
|
|
});
|
|
}
|
|
|
|
const client = await mcpDb.getClient(client_id);
|
|
if (!client) {
|
|
return res.status(404).json({
|
|
error: 'invalid_client',
|
|
error_description: `Unknown client_id: ${client_id}`,
|
|
});
|
|
}
|
|
|
|
let registeredUris = [];
|
|
try {
|
|
registeredUris = JSON.parse(client.redirectUris);
|
|
} catch {
|
|
registeredUris = [client.redirectUris];
|
|
}
|
|
|
|
if (!redirect_uri || !isRedirectUriAllowed(redirect_uri, registeredUris)) {
|
|
return res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'Invalid or unregistered redirect_uri',
|
|
});
|
|
}
|
|
|
|
if (response_type !== 'code') {
|
|
return res.status(400).json({
|
|
error: 'unsupported_response_type',
|
|
error_description: 'Only response_type=code is supported',
|
|
});
|
|
}
|
|
|
|
if (!code_challenge || code_challenge_method !== 'S256') {
|
|
return res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'PKCE code_challenge with method S256 is required',
|
|
});
|
|
}
|
|
|
|
return res.json({
|
|
client: {
|
|
clientId: client.clientId,
|
|
clientName: client.clientName,
|
|
scope: client.scope,
|
|
},
|
|
user: {
|
|
id: req.user.id,
|
|
email: req.user.email,
|
|
},
|
|
params: {
|
|
clientId: client_id,
|
|
redirectUri: redirect_uri,
|
|
codeChallenge: code_challenge,
|
|
codeChallengeMethod: code_challenge_method,
|
|
state,
|
|
scope: scope || client.scope || 'mcp:tools',
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error('[mcp] Authorize prompt error:', err);
|
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /mcp/authorize:
|
|
* post:
|
|
* tags: [MCP]
|
|
* summary: Grant or deny MCP client authorization
|
|
* description: Issues an OAuth authorization code or refusal for the requesting MCP client on behalf of the authenticated user.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [client_id, redirect_uri, action]
|
|
* properties:
|
|
* client_id:
|
|
* type: string
|
|
* redirect_uri:
|
|
* type: string
|
|
* code_challenge:
|
|
* type: string
|
|
* code_challenge_method:
|
|
* type: string
|
|
* state:
|
|
* type: string
|
|
* scope:
|
|
* type: string
|
|
* action:
|
|
* type: string
|
|
* enum: [allow, deny]
|
|
* responses:
|
|
* 200:
|
|
* description: Success with redirect URL containing code or error
|
|
* 400:
|
|
* description: Invalid request parameters
|
|
* 401:
|
|
* description: User not authenticated
|
|
* 404:
|
|
* description: Client not found
|
|
*/
|
|
app.post(`${basePath}/mcp/authorize`, async (req, res) => {
|
|
try {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
const {
|
|
client_id,
|
|
redirect_uri,
|
|
code_challenge,
|
|
code_challenge_method = 'S256',
|
|
state = '',
|
|
scope = 'mcp:tools',
|
|
action = 'allow',
|
|
} = req.body || {};
|
|
|
|
if (!client_id) {
|
|
return res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'Missing client_id',
|
|
});
|
|
}
|
|
|
|
const client = await mcpDb.getClient(client_id);
|
|
if (!client) {
|
|
return res.status(404).json({
|
|
error: 'invalid_client',
|
|
error_description: `Unknown client_id: ${client_id}`,
|
|
});
|
|
}
|
|
|
|
let registeredUris = [];
|
|
try {
|
|
registeredUris = JSON.parse(client.redirectUris);
|
|
} catch {
|
|
registeredUris = [client.redirectUris];
|
|
}
|
|
|
|
if (!redirect_uri || !isRedirectUriAllowed(redirect_uri, registeredUris)) {
|
|
return res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'Invalid or unregistered redirect_uri',
|
|
});
|
|
}
|
|
|
|
const redirectUrl = new URL(redirect_uri);
|
|
|
|
if (action === 'deny') {
|
|
redirectUrl.searchParams.set('error', 'access_denied');
|
|
if (state) redirectUrl.searchParams.set('state', state);
|
|
|
|
if (req.is('application/json') || req.headers.accept?.includes('application/json')) {
|
|
return res.json({ ok: false, redirectUrl: redirectUrl.toString() });
|
|
}
|
|
return res.redirect(redirectUrl.toString());
|
|
}
|
|
|
|
if (!code_challenge || code_challenge_method !== 'S256') {
|
|
return res.status(400).json({
|
|
error: 'invalid_request',
|
|
error_description: 'PKCE code_challenge with method S256 is required',
|
|
});
|
|
}
|
|
|
|
const code = randomBytes(32).toString('hex');
|
|
await mcpDb.createAuthCode({
|
|
code,
|
|
clientId: client_id,
|
|
googleAuthId: req.user.id,
|
|
redirectUri: redirect_uri,
|
|
codeChallenge: code_challenge,
|
|
codeChallengeMethod: code_challenge_method,
|
|
scope: scope || client.scope || 'mcp:tools',
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
|
|
});
|
|
|
|
redirectUrl.searchParams.set('code', code);
|
|
if (state) redirectUrl.searchParams.set('state', state);
|
|
|
|
if (req.is('application/json') || req.headers.accept?.includes('application/json')) {
|
|
return res.json({ ok: true, redirectUrl: redirectUrl.toString() });
|
|
}
|
|
return res.redirect(redirectUrl.toString());
|
|
} catch (err) {
|
|
console.error('[mcp] Authorize action error:', err);
|
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /mcp/authorizations:
|
|
* get:
|
|
* tags: [MCP]
|
|
* summary: List active MCP client authorizations
|
|
* description: Lists all MCP clients authorized by the authenticated user with token counts and authorization timestamps.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of active user authorizations
|
|
* 401:
|
|
* description: User not authenticated
|
|
*/
|
|
app.get(`${basePath}/mcp/authorizations`, async (req, res) => {
|
|
try {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
const authorizations = await mcpDb.listAuthorizations(req.user.id);
|
|
res.json({ authorizations });
|
|
} catch (err) {
|
|
console.error('[mcp] List authorizations error:', err);
|
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /mcp/authorizations/{clientId}:
|
|
* delete:
|
|
* tags: [MCP]
|
|
* summary: Revoke MCP client authorizations
|
|
* description: Revokes all tokens and authorizations granted to an MCP client by the authenticated user.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: clientId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Authorizations revoked
|
|
* 400:
|
|
* description: Missing clientId
|
|
* 401:
|
|
* description: User not authenticated
|
|
*/
|
|
app.delete(`${basePath}/mcp/authorizations/:clientId`, async (req, res) => {
|
|
try {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
const { clientId } = req.params;
|
|
if (!clientId) {
|
|
return res.status(400).json({ error: 'invalid_request', error_description: 'clientId is required' });
|
|
}
|
|
await mcpDb.revokeAuthorizations(req.user.id, clientId);
|
|
res.json({ ok: true, message: 'Authorizations revoked' });
|
|
} catch (err) {
|
|
console.error('[mcp] Revoke authorizations error:', err);
|
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /mcp/clients:
|
|
* get:
|
|
* tags: [MCP]
|
|
* summary: List registered MCP clients
|
|
* description: Lists all registered OAuth MCP clients.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of registered clients
|
|
* 401:
|
|
* description: User not authenticated
|
|
*/
|
|
app.get(`${basePath}/mcp/clients`, async (req, res) => {
|
|
try {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
const clients = await mcpDb.listClients();
|
|
res.json({ clients });
|
|
} catch (err) {
|
|
console.error('[mcp] List clients error:', err);
|
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /mcp/clients/{clientId}:
|
|
* delete:
|
|
* tags: [MCP]
|
|
* summary: Delete registered MCP client
|
|
* description: Deletes an MCP client registration.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: clientId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Client deleted
|
|
* 400:
|
|
* description: Missing clientId
|
|
* 401:
|
|
* description: User not authenticated
|
|
*/
|
|
app.delete(`${basePath}/mcp/clients/:clientId`, async (req, res) => {
|
|
try {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
const { clientId } = req.params;
|
|
if (!clientId) {
|
|
return res.status(400).json({ error: 'invalid_request', error_description: 'clientId is required' });
|
|
}
|
|
await mcpDb.deleteClient(clientId);
|
|
res.json({ ok: true, message: 'Client deleted' });
|
|
} catch (err) {
|
|
console.error('[mcp] Delete client error:', err);
|
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function escapeHtml(str) {
|
|
if (typeof str !== 'string') return '';
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|