feat: per-user MCP auth + complete moderation audit trail (#445)#446
Conversation
Replace shared MCP_ADMIN_TOKEN with per-user tokens stored in the users table. MCP server authenticates via URL-embedded token (e.g. /mcp/<token>) and resolves to the real user ID. All MCP actions are now attributed to the authenticated user. - Add mcp_token column to users table (migration 069) - MCP auth: URL path token + Bearer header fallback + env var bootstrap - Mount MCP handler on Express main port (body parser bypass) - Settings UI: MCP tab with connection URL, reveal/copy/rotate - Fix 10 audit trail gaps: createItem, admin create, media ops, deny-list - Auto-publish/reject sets moderated_by to Auto-Publisher (-1) - Only pending items have moderated_by=NULL Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces per-user Model Context Protocol (MCP) authentication tokens, allowing users to connect AI assistants to the application. It adds database migrations, backend routes, and middleware to support MCP token generation, regeneration, and request handling, while also updating the frontend with an MCP settings tab. Feedback on these changes highlights several critical issues: handleMcpRequest lacks error handling and robust buffer length checks for crypto.timingSafeEqual, which could crash the server; the /mcp/:token route is registered too late in server.js and will be intercepted by the wildcard handler; and the frontend McpSettings component needs better error handling for token regeneration and safe clipboard access in non-secure contexts.
| async function handleMcpRequest(req, res, pool, boss) { | ||
| const urlMatch = req.url.match(/^\/mcp\/([A-Za-z0-9_-]+)$/); | ||
| const urlToken = urlMatch ? urlMatch[1] : null; | ||
| const authHeader = req.headers.authorization; | ||
| const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; | ||
| const token = urlToken || bearerToken; | ||
|
|
||
| if (!token) { | ||
| res.writeHead(401, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Unauthorized' })); | ||
| return; | ||
| } | ||
|
|
||
| let mcpUserId = null; | ||
|
|
||
| const userResult = await pool.query( | ||
| 'SELECT id, is_admin FROM users WHERE mcp_token = $1', [token] | ||
| ); | ||
| if (userResult.rows.length > 0) { | ||
| mcpUserId = userResult.rows[0].id; | ||
| } else { | ||
| const envToken = process.env.MCP_ADMIN_TOKEN; | ||
| if (envToken && token.length === envToken.length && | ||
| crypto.timingSafeEqual(Buffer.from(token), Buffer.from(envToken))) { | ||
| const adminResult = await pool.query( | ||
| 'SELECT id FROM users WHERE is_admin = TRUE ORDER BY id LIMIT 1' | ||
| ); | ||
| mcpUserId = adminResult.rows.length > 0 ? adminResult.rows[0].id : null; | ||
| } | ||
| } | ||
|
|
||
| if (!mcpUserId) { | ||
| res.writeHead(401, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ error: 'Unauthorized' })); | ||
| return; | ||
| } | ||
|
|
||
| const server = new McpServer({ name: 'rotv-admin', version: '1.0.0' }); | ||
| registerTools(server, pool, boss, mcpUserId); | ||
|
|
||
| const transport = new StreamableHTTPServerTransport({ | ||
| sessionIdGenerator: undefined | ||
| }); | ||
|
|
||
| await server.connect(transport); | ||
| await transport.handleRequest(req, res); | ||
| } |
There was a problem hiding this comment.
The handleMcpRequest function lacks error handling and can throw unhandled exceptions (e.g., from database queries, MCP server connection, or transport handling), which can crash the Node.js process or hang the connection. Additionally, crypto.timingSafeEqual throws a TypeError if the compared buffers have different byte lengths. Comparing string lengths (token.length === envToken.length) is insufficient if multi-byte characters are present.
Wrapping the function body in a try...catch block and comparing the byte lengths of the buffers before calling crypto.timingSafeEqual ensures robustness and security.
async function handleMcpRequest(req, res, pool, boss) {
try {
const urlMatch = req.url.match(/^\/mcp\/([A-Za-z0-9_-]+)$/);
const urlToken = urlMatch ? urlMatch[1] : null;
const authHeader = req.headers.authorization;
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
const token = urlToken || bearerToken;
if (!token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
let mcpUserId = null;
const userResult = await pool.query(
'SELECT id, is_admin FROM users WHERE mcp_token = $1', [token]
);
if (userResult.rows.length > 0) {
mcpUserId = userResult.rows[0].id;
} else {
const envToken = process.env.MCP_ADMIN_TOKEN;
if (envToken) {
const tokenBuf = Buffer.from(token);
const envTokenBuf = Buffer.from(envToken);
if (tokenBuf.length === envTokenBuf.length && crypto.timingSafeEqual(tokenBuf, envTokenBuf)) {
const adminResult = await pool.query(
'SELECT id FROM users WHERE is_admin = TRUE ORDER BY id LIMIT 1'
);
mcpUserId = adminResult.rows.length > 0 ? adminResult.rows[0].id : null;
}
}
}
if (!mcpUserId) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const server = new McpServer({ name: 'rotv-admin', version: '1.0.0' });
registerTools(server, pool, boss, mcpUserId);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await server.connect(transport);
await transport.handleRequest(req, res);
} catch (err) {
console.error('MCP request handling failed:', err);
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
}
}| startMcpServer(pool, app.get('boss'), parseInt(process.env.MCP_PORT || '3001')); | ||
| app.all('/mcp/:token', mcpMiddleware(pool, app.get('boss'))); |
There was a problem hiding this comment.
Registering the /mcp/:token route handler inside the start() function places it after the wildcard app.get('*') handler (which is registered at the top level during module load). In Express, routes are matched in the order of registration, meaning any GET request to /mcp/:token will be intercepted by app.get('*') and serve index.html instead of reaching the MCP middleware.
To fix this, register the route handler at the top level (before app.get('*')) and dynamically resolve the boss instance from req.app.get('boss').
| startMcpServer(pool, app.get('boss'), parseInt(process.env.MCP_PORT || '3001')); | |
| app.all('/mcp/:token', mcpMiddleware(pool, app.get('boss'))); | |
| startMcpServer(pool, app.get('boss'), parseInt(process.env.MCP_PORT || '3001')); |
| const handleCopy = () => { | ||
| navigator.clipboard.writeText(mcpUrl); | ||
| setCopied(true); | ||
| setTimeout(() => setCopied(false), 2000); | ||
| }; |
There was a problem hiding this comment.
In non-secure contexts (e.g., local development over HTTP in some browsers), navigator.clipboard is undefined. Directly calling navigator.clipboard.writeText will throw a TypeError and crash the component. Additionally, writeText returns a Promise that should have its rejection handled to avoid unhandled promise rejections.
const handleCopy = () => {
if (navigator.clipboard) {
navigator.clipboard.writeText(mcpUrl)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('Failed to copy token:', err);
});
} else {
console.warn('Clipboard API not available');
}
};| const handleRegenerate = async () => { | ||
| if (!confirm('Rotate your MCP key? Your current URL will stop working immediately.')) return; | ||
| setRegenerating(true); | ||
| try { | ||
| const res = await fetch('/api/user/settings/mcp-token/regenerate', { | ||
| method: 'POST', credentials: 'include' | ||
| }); | ||
| if (res.ok) { | ||
| const data = await res.json(); | ||
| setToken(data.token); | ||
| setRevealed(false); | ||
| } | ||
| } finally { | ||
| setRegenerating(false); | ||
| } | ||
| }; |
There was a problem hiding this comment.
The handleRegenerate function performs an asynchronous fetch request but does not catch potential network errors or handle non-ok HTTP responses. This can lead to unhandled promise rejections and a poor user experience if the request fails.
const handleRegenerate = async () => {
if (!confirm('Rotate your MCP key? Your current URL will stop working immediately.')) return;
setRegenerating(true);
try {
const res = await fetch('/api/user/settings/mcp-token/regenerate', {
method: 'POST', credentials: 'include'
});
if (res.ok) {
const data = await res.json();
setToken(data.token);
setRevealed(false);
} else {
alert('Failed to rotate key. Please try again.');
}
} catch (err) {
console.error('Error rotating key:', err);
alert('A network error occurred. Please try again.');
} finally {
setRegenerating(false);
}
};… user (PR #446 review) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
MCP_ADMIN_TOKENwith per-user tokens in theuserstable — MCP actions are now attributed to the real user/mcp/<token>) like Postiz — no Bearer header neededmoderated_by = -1(Auto-Publisher system user)moderated_by = NULLCloses #445
Test plan
./run.sh buildpasses🤖 Generated with Claude Code