Files
dashback/src/api/routes/mcp.js
2026-10-18 14:29:01 +02:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}