mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix: lightweight /health endpoint — SELECT 1 instead of getStats() On large brains (96K+ pages), getStats() runs 6× count(*) queries that routinely exceed the 3s HEALTH_TIMEOUT_MS through PgBouncer. This produces false 503s that cause external health monitors (cron, Fly.io, k8s) to restart otherwise-healthy servers — which in turn creates advisory lock pile-ups when multiple serve instances compete for the migration lock. Changes: - /health now runs `SELECT 1` for liveness (sub-millisecond) - ?full=true opt-in preserves the old getStats() behavior - /admin/api/health-indicators still returns full stats - probeHealth() retained for callers that need it * refactor(health): extract probeLiveness, move full stats to /admin/api/full-stats Addresses outside-voice review of PR #701. The original ?full=true query-param escape hatch was withdrawn because the loopback IP gate's correctness depended on app.set('trust proxy', 'loopback') semantics holding under proxy/XFF misconfiguration, and the PR's own comment misidentified /admin/api/health-indicators as a full-stats endpoint when it actually returns only {expiring_soon, error_rate}. Changes: - src/commands/serve-http.ts: new probeLiveness(sql, engineName, version, timeoutMs) helper next to probeHealth. Same shape, same return type, same finally-block clearTimeout discipline. /health is now a 2-line dispatch through probeLiveness. Removes ?full=true entirely. Adds new admin route /admin/api/full-stats behind the existing requireAdmin middleware that returns probeHealth(engine, ...) — same body shape /health used to expose (status, version, engine, page_count, chunk_count, embedded_count, link_count, tag_count, timeline_entry_count). - test/serve-http-health.test.ts: 4 new probeLiveness cases (success-shape regression with exact-keys assertion, timeout, db-error, timer-cleanup under 100 concurrent probes). - test/e2e/serve-http-oauth.test.ts: existing /health body-shape assertion rewritten to the liveness-only contract (page_count must NOT be present); 2 new admin-stats cases (401 without cookie, 200 with magic-link-derived admin cookie returns getStats() body). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: bump version and changelog (v0.28.10) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: update CLAUDE.md serve-http.ts annotation for v0.28.10 split Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(claude): explicit "run E2E without asking" + schema-bootstrap step The previous wording ("Always run E2E tests when they exist") was easy to read as a soft preference; in practice agents kept proposing the run instead of just doing it. Make the policy unmistakable: if there's a relevant E2E and you want to verify behavior, just spin up the DB and run. Also documents the schema-bootstrap step that bit a fresh container today — `oauth_clients` doesn't exist on a virgin pgvector image until `gbrain doctor` (or any engine-connecting command) triggers `initSchema()`. `apply-migrations` alone runs ALTER-style migrations on top of an already-bootstrapped schema; it does not seed base tables. Tests that bypass the engine via execSync against `gbrain auth register-client` hit the DB directly and need bootstrap first. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(serve-http): persist mcp_request_log on every JSON-RPC method + admin-scope F7 tests Closes the 4 pre-existing E2E failures in test/e2e/serve-http-oauth.test.ts that surfaced when DATABASE_URL was set on the v0.28.10 branch. The branch isn't the cause — these were broken on master too (verified by checking out origin/master's serve-http.ts + test file: 0/4 pass). Owning them here as a bisectable commit. Two root causes, both in serve-http.ts's /mcp logging + scope discipline. 1. mcp_request_log was only INSERTed inside the tools/call success/error paths. tools/list, the unknown-op early-return, and the insufficient-scope early-return all returned without logging. The v0.26.3 persistence regression test calls tools/list + tools/call non-existent and expects >= 2 rows; on the prior implementation it got 0. The agent_name resolution test (single tools/list, expects the row) had the same shape. Fix: log every JSON-RPC method exit point. tools/list logs operation = 'tools/list' with status='success' (lists never fail). Unknown-op logs operation = the attempted name with error_message starting 'unknown_operation:'. Insufficient-scope logs operation = the attempted name with error_message 'insufficient_scope: requires <scope>'. Admin agents auditing /admin/api/requests now see the full attempt log, not just successful valid-op calls. 2. The F7 RCE-regression tests minted 'read write' tokens to assert submit_job for protected names ('shell', 'subagent') gets rejected. But submit_job's required scope is 'admin' (set by hasScope-aware v0.28 enforcement), so a 'read write' token gets rejected with insufficient_scope BEFORE reaching the F7 protected-name guard at operations.ts:1527. The test's assertion checked for 'permission_denied' / 'cannot be submitted over MCP' — neither appears in an insufficient_scope response — so 'rejected' computed to false even though the call was actually rejected. Worse, if someone removed the F7 guard, the test would still pass because scope check would catch it: regression-test integrity failure. Fix: register the e2e-oauth-test client with admin in its allowed scopes (was 'read write', now 'read write admin'), and have F7 tests mint admin-scoped tokens explicitly. Adding admin to the client's allowed ceiling does not auto-grant it to subset-mint calls — other tests minting 'read' / 'read write' still get the subset they ask for. The persistence test's assertion 'rows.find(r => r.operation === "tools/call")' was also updated to match the actual logging convention (operation = inner tool name on call paths, JSON-RPC method on list/scope/unknown paths). E2E result: 29/29 pass on a fresh pgvector container (fixed 4, kept the 25 that were passing). Unit suite: 4191 pass, 0 fail, unchanged. Typecheck: clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: regenerate llms-full.txt after CLAUDE.md update Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
999 lines
45 KiB
TypeScript
999 lines
45 KiB
TypeScript
/**
|
|
* GBrain HTTP MCP server with OAuth 2.1.
|
|
*
|
|
* Combines:
|
|
* - MCP SDK's mcpAuthRouter (OAuth endpoints: /authorize, /token, /register, /revoke)
|
|
* - Custom client_credentials handler (SDK doesn't support CC grant)
|
|
* - MCP tool calls at /mcp with bearer auth + scope enforcement
|
|
* - Admin dashboard at /admin with cookie auth
|
|
* - SSE live activity feed at /admin/events
|
|
* - Health check at /health
|
|
*/
|
|
|
|
import express from 'express';
|
|
import type { Request, Response, NextFunction } from 'express';
|
|
import cookieParser from 'cookie-parser';
|
|
import cors from 'cors';
|
|
import rateLimit from 'express-rate-limit';
|
|
import { randomBytes, createHash, timingSafeEqual } from 'crypto';
|
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
import type { BrainEngine } from '../core/engine.ts';
|
|
import { operations, OperationError } from '../core/operations.ts';
|
|
import type { OperationContext, AuthInfo } from '../core/operations.ts';
|
|
import { GBrainOAuthProvider } from '../core/oauth-provider.ts';
|
|
import type { SqlQuery } from '../core/oauth-provider.ts';
|
|
import { hasScope, ALLOWED_SCOPES_LIST } from '../core/scope.ts';
|
|
import { summarizeMcpParams } from '../mcp/dispatch.ts';
|
|
import { loadConfig } from '../core/config.ts';
|
|
import { buildError, serializeError } from '../core/errors.ts';
|
|
import { VERSION } from '../version.ts';
|
|
import * as db from '../core/db.ts';
|
|
|
|
/**
|
|
* /health endpoint timeout. 3s rather than 5s: Fly.io's default
|
|
* health-check timeout is 5s, so returning 503 right at the orchestrator
|
|
* deadline races with the orchestrator recording the request as a timeout.
|
|
* 3s leaves 2s of headroom for TCP, response framing, and clock skew.
|
|
*/
|
|
export const HEALTH_TIMEOUT_MS = 3000;
|
|
|
|
export type ProbeHealthResult =
|
|
| { ok: true; status: 200; body: { status: 'ok'; version: string; engine: string; [k: string]: unknown } }
|
|
| { ok: false; status: 503; body: { error: 'service_unavailable'; error_description: string } };
|
|
|
|
/**
|
|
* Pure async health probe. Races `engine.getStats()` against a timeout,
|
|
* returns a tagged result. No Express coupling — easy to unit-test with a
|
|
* mock engine. The /health route handler is a thin wrapper around this.
|
|
*/
|
|
export async function probeHealth(
|
|
engine: BrainEngine,
|
|
engineName: string,
|
|
version: string,
|
|
timeoutMs: number = HEALTH_TIMEOUT_MS,
|
|
): Promise<ProbeHealthResult> {
|
|
// Capture the handle so we can clearTimeout when getStats() wins. Without
|
|
// this, every fast /health request leaves a 3s pending timer in the event
|
|
// loop until it fires — under high probe rates this builds up a rolling
|
|
// backlog of timers and avoidable wakeups. Both adversarial reviewers
|
|
// (Claude + Codex) flagged this independently.
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
try {
|
|
const stats = await Promise.race([
|
|
engine.getStats(),
|
|
new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error('health_timeout')), timeoutMs);
|
|
}),
|
|
]);
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: { status: 'ok', version, engine: engineName, ...stats },
|
|
};
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'unknown';
|
|
return {
|
|
ok: false,
|
|
status: 503,
|
|
body: {
|
|
error: 'service_unavailable',
|
|
error_description: msg === 'health_timeout'
|
|
? 'Health check timed out (database pool may be saturated)'
|
|
: 'Database connection failed',
|
|
},
|
|
};
|
|
} finally {
|
|
// Clear the timer regardless of which branch won the race. No-op when
|
|
// the timer already fired (we're in the timeout-rejection catch block).
|
|
if (timer !== null) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lightweight liveness probe. Races `SELECT 1` against the same timeout
|
|
* `probeHealth` uses, returns the same tagged-union result type, but the
|
|
* 200 body is intentionally bare: `{status, version, engine}` — no engine
|
|
* stats. Stats moved to `/admin/api/full-stats` (admin auth) in v0.28.10
|
|
* because `getStats()`'s six count(*) queries exceeded HEALTH_TIMEOUT_MS
|
|
* on production brains through PgBouncer, producing false 503s that
|
|
* triggered orchestrator restart cascades and advisory-lock pile-ups.
|
|
*/
|
|
export async function probeLiveness(
|
|
sql: SqlQuery,
|
|
engineName: string,
|
|
version: string,
|
|
timeoutMs: number = HEALTH_TIMEOUT_MS,
|
|
): Promise<ProbeHealthResult> {
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
try {
|
|
await Promise.race([
|
|
sql`SELECT 1`,
|
|
new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error('health_timeout')), timeoutMs);
|
|
}),
|
|
]);
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: { status: 'ok', version, engine: engineName },
|
|
};
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'unknown';
|
|
return {
|
|
ok: false,
|
|
status: 503,
|
|
body: {
|
|
error: 'service_unavailable',
|
|
error_description: msg === 'health_timeout'
|
|
? 'Health check timed out (database pool may be saturated)'
|
|
: 'Database connection failed',
|
|
},
|
|
};
|
|
} finally {
|
|
if (timer !== null) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
interface ServeHttpOptions {
|
|
port: number;
|
|
tokenTtl: number;
|
|
enableDcr: boolean;
|
|
/**
|
|
* Public URL the server is reachable at (e.g., https://brain.example.com).
|
|
* Used as the OAuth issuer in discovery metadata. Defaults to
|
|
* http://localhost:{port} when unset. Required for production deployments
|
|
* behind reverse proxies, ngrok tunnels, or any non-loopback URL — the
|
|
* issuer claim in tokens MUST match the discovery URL clients hit.
|
|
*/
|
|
publicUrl?: string;
|
|
/**
|
|
* When true, write raw request payloads to mcp_request_log + the admin SSE
|
|
* feed. Default false: payloads are summarized via dispatch.summarizeMcpParams
|
|
* (declared keys only, no values, no attacker-controlled key names).
|
|
*
|
|
* Operators running gbrain on their own laptop and debugging agent behavior
|
|
* can flip this on with `--log-full-params`. The flag prints a loud warning
|
|
* at startup so the privacy posture change is visible.
|
|
*/
|
|
logFullParams?: boolean;
|
|
}
|
|
|
|
export async function runServeHttp(engine: BrainEngine, options: ServeHttpOptions) {
|
|
const { port, tokenTtl, enableDcr, publicUrl, logFullParams } = options;
|
|
const config = loadConfig() || { engine: 'pglite' as const };
|
|
|
|
if (logFullParams) {
|
|
console.error(
|
|
'[serve-http] WARNING: --log-full-params writes raw request payloads to mcp_request_log + SSE feed. Disable for shared dashboards or production.',
|
|
);
|
|
}
|
|
|
|
// Get raw SQL connection for OAuth provider
|
|
const sql = db.getConnection() as SqlQuery;
|
|
|
|
// Initialize OAuth provider. F12 cleanup: DCR-disable now flips a
|
|
// constructor option instead of monkey-patching `_clientsStore` after
|
|
// construction. Same outcome (no /register endpoint when --enable-dcr
|
|
// is not passed); cleaner shape for tests and future maintainers.
|
|
const oauthProvider = new GBrainOAuthProvider({
|
|
sql,
|
|
tokenTtl,
|
|
dcrDisabled: !enableDcr,
|
|
});
|
|
|
|
// Sweep expired tokens on startup (non-blocking)
|
|
try {
|
|
const swept = await oauthProvider.sweepExpiredTokens();
|
|
if (swept > 0) console.error(`Swept ${swept} expired tokens`);
|
|
} catch (e) {
|
|
console.error('Token sweep failed (non-blocking):', e instanceof Error ? e.message : e);
|
|
}
|
|
|
|
// Generate bootstrap token for admin dashboard
|
|
const bootstrapToken = randomBytes(32).toString('hex');
|
|
const bootstrapHash = createHash('sha256').update(bootstrapToken).digest('hex');
|
|
const adminSessions = new Map<string, number>(); // sessionId → expiresAt
|
|
|
|
// SSE clients for live activity feed
|
|
const sseClients = new Set<express.Response>();
|
|
|
|
// Broadcast MCP request event to all SSE clients
|
|
function broadcastEvent(event: Record<string, unknown>) {
|
|
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
for (const client of sseClients) {
|
|
try { client.write(data); } catch { sseClients.delete(client); }
|
|
}
|
|
}
|
|
|
|
// Express 5 app
|
|
const app = express();
|
|
app.set('trust proxy', 'loopback'); // Caddy/Tailscale reverse proxy on localhost
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cookie parsing — required for /admin auth (express 5 has no built-in)
|
|
// ---------------------------------------------------------------------------
|
|
app.use(cookieParser());
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CORS
|
|
// ---------------------------------------------------------------------------
|
|
app.use('/mcp', cors());
|
|
app.use('/token', cors());
|
|
app.use('/authorize', cors());
|
|
app.use('/register', cors());
|
|
app.use('/revoke', cors());
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom client_credentials handler (before mcpAuthRouter)
|
|
// SDK's token handler only supports authorization_code and refresh_token
|
|
// ---------------------------------------------------------------------------
|
|
const ccRateLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 50,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'too_many_requests', error_description: 'Rate limit exceeded. Try again in 15 minutes.' },
|
|
});
|
|
|
|
// Magic-link rate limiter: 10 requests/min/IP. The bootstrap token is
|
|
// 64-char hex (unguessable) so brute-forcing is computationally
|
|
// infeasible — but a misconfigured client looping on /admin/auth/:bad
|
|
// could DoS the server's CPU on sha256 + the inline HTML response.
|
|
// Defense-in-depth on the highest-privileged URL the server exposes.
|
|
const adminAuthRateLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: 'Too many magic-link attempts. Wait a minute before trying again.',
|
|
});
|
|
|
|
app.post('/token', ccRateLimiter, express.urlencoded({ extended: false }), async (req, res, next) => {
|
|
if (req.body?.grant_type !== 'client_credentials') {
|
|
return next(); // Fall through to SDK's token handler
|
|
}
|
|
|
|
try {
|
|
const { client_id, client_secret, scope } = req.body;
|
|
if (!client_id || !client_secret) {
|
|
res.status(400).json({ error: 'invalid_request', error_description: 'client_id and client_secret required' });
|
|
return;
|
|
}
|
|
|
|
const tokens = await oauthProvider.exchangeClientCredentials(client_id, client_secret, scope);
|
|
res.json(tokens);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : 'Unknown error';
|
|
res.status(400).json({ error: 'invalid_grant', error_description: msg });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP SDK Auth Router (OAuth endpoints)
|
|
// ---------------------------------------------------------------------------
|
|
// The issuer URL goes into discovery metadata + token iss claims. It MUST
|
|
// match the URL clients actually hit, or strict OAuth clients reject tokens
|
|
// (RFC 8414 §3.3). Honor --public-url for production deployments behind
|
|
// reverse proxies / tunnels; default to localhost for dev.
|
|
const issuerUrl = new URL(publicUrl || `http://localhost:${port}`);
|
|
|
|
// F9: cookie `secure` flag honors both the request's TLS state (req.secure
|
|
// is set when express trust-proxy lands an X-Forwarded-Proto: https) AND
|
|
// the operator's declared issuer protocol (so a Cloudflare-tunnel deploy
|
|
// where the connection inside the tunnel looks like http but the public
|
|
// URL is https still tags cookies Secure). Without this, an attacker on
|
|
// the network path could MITM the admin cookie over plaintext.
|
|
const adminCookie = (req: Request, maxAge: number) => ({
|
|
httpOnly: true,
|
|
sameSite: 'strict' as const,
|
|
secure: req.secure || issuerUrl.protocol === 'https:',
|
|
maxAge,
|
|
path: '/admin',
|
|
});
|
|
|
|
const authRouterOptions: any = {
|
|
provider: oauthProvider,
|
|
issuerUrl,
|
|
// v0.28: scopesSupported sourced from ALLOWED_SCOPES_LIST so MCP clients
|
|
// (Claude Desktop, ChatGPT, Perplexity) can discover sources_admin and
|
|
// users_admin via /.well-known/oauth-authorization-server. The legacy
|
|
// ['read','write','admin'] list left those new scopes invisible.
|
|
scopesSupported: [...ALLOWED_SCOPES_LIST],
|
|
resourceName: 'GBrain MCP Server',
|
|
};
|
|
|
|
// F12: DCR disable lives on the provider's constructor option above. The
|
|
// SDK's mcpAuthRouter reads provider.clientsStore once and only wires up
|
|
// /register when the store exposes registerClient — so passing dcrDisabled
|
|
// to the constructor is sufficient. No monkey-patching here.
|
|
|
|
const authRouter = mcpAuthRouter(authRouterOptions);
|
|
|
|
// Patch the SDK's OAuth metadata to include client_credentials grant type.
|
|
// The SDK hardcodes ['authorization_code', 'refresh_token'] — we intercept
|
|
// the response and add client_credentials before it reaches the client.
|
|
app.use((req, res, next) => {
|
|
if (req.path === '/.well-known/oauth-authorization-server' && req.method === 'GET') {
|
|
const origJson = res.json.bind(res);
|
|
(res as any).json = (body: any) => {
|
|
if (body?.grant_types_supported && !body.grant_types_supported.includes('client_credentials')) {
|
|
body.grant_types_supported.push('client_credentials');
|
|
}
|
|
return origJson(body);
|
|
};
|
|
}
|
|
next();
|
|
});
|
|
|
|
app.use(authRouter);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Health check — liveness only. Full engine stats live at
|
|
// /admin/api/full-stats (requireAdmin). See probeLiveness above for the why.
|
|
// ---------------------------------------------------------------------------
|
|
app.get('/health', async (_req, res) => {
|
|
const result = await probeLiveness(sql, config.engine || 'pglite', VERSION);
|
|
res.status(result.status).json(result.body);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin authentication (cookie-based)
|
|
// ---------------------------------------------------------------------------
|
|
// POST /admin/login — JSON body with token (for programmatic/UI login)
|
|
// Constant-time hex compare. Both inputs are sha256 hex (64 chars),
|
|
// so they're always equal length. timingSafeEqual throws on length
|
|
// mismatch — we already short-circuit on non-string above. Catches
|
|
// would-be timing oracles even though the inputs are pre-hashed
|
|
// (defense-in-depth on the hash bits).
|
|
function safeHexEqual(a: string, b: string): boolean {
|
|
if (a.length !== b.length) return false;
|
|
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
|
}
|
|
|
|
app.post('/admin/login', express.json(), (req, res) => {
|
|
const token = req.body?.token;
|
|
if (!token || typeof token !== 'string') {
|
|
res.status(400).json({ error: 'Token required' });
|
|
return;
|
|
}
|
|
|
|
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
if (!safeHexEqual(tokenHash, bootstrapHash)) {
|
|
res.status(401).json({ error: 'Invalid token. Check your terminal output.' });
|
|
return;
|
|
}
|
|
|
|
const sessionId = randomBytes(32).toString('hex');
|
|
const expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
|
|
adminSessions.set(sessionId, expiresAt);
|
|
|
|
res.cookie('gbrain_admin', sessionId, adminCookie(req, 24 * 60 * 60 * 1000));
|
|
res.json({ status: 'authenticated' });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Magic-link nonce store (single-use) — D11 + D12
|
|
//
|
|
// Trust model (codex review pushback resolved this):
|
|
// - Bootstrap token is the long-term server admin secret. Printed to
|
|
// stderr at startup; lives in operator's terminal scrollback only.
|
|
// - Magic-link URLs use one-time NONCES (not the bootstrap token).
|
|
// Agent calls POST /admin/api/issue-magic-link with the bootstrap
|
|
// token in Authorization: Bearer to mint a nonce. Nonce expires in
|
|
// 5 minutes if unredeemed; consumed on first redemption.
|
|
// - Bootstrap token never appears in a URL → no leakage via browser
|
|
// history, proxy access logs, or Referer headers.
|
|
// - Cookie sessions are HttpOnly + SameSite=Strict, but the bootstrap
|
|
// token itself is never client-side-readable JS state (no
|
|
// localStorage/sessionStorage cache — D12).
|
|
//
|
|
// Memory bound: nonces auto-purged on expiry sweep + LRU cap of 1000
|
|
// entries (an attacker minting millions can't OOM the server).
|
|
// ---------------------------------------------------------------------------
|
|
const magicLinkNonces = new Map<string, number>(); // nonce → expiresAt
|
|
const consumedNonces = new Set<string>();
|
|
const NONCE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
const NONCE_LRU_CAP = 1000;
|
|
|
|
// Best-effort GC: remove expired entries on each issue/redeem call.
|
|
function pruneExpiredNonces() {
|
|
const now = Date.now();
|
|
for (const [nonce, expiresAt] of magicLinkNonces) {
|
|
if (expiresAt < now) magicLinkNonces.delete(nonce);
|
|
}
|
|
// F10: bound the live-nonce store too. An attacker with the bootstrap
|
|
// token (or a misbehaving agent) could mint nonces faster than they
|
|
// expire. Map iteration order is insertion order, so dropping from the
|
|
// front gives a simple FIFO eviction matching the consumedNonces pattern.
|
|
if (magicLinkNonces.size > NONCE_LRU_CAP) {
|
|
const drop = magicLinkNonces.size - NONCE_LRU_CAP;
|
|
const it = magicLinkNonces.keys();
|
|
for (let i = 0; i < drop; i++) magicLinkNonces.delete(it.next().value as string);
|
|
}
|
|
// Cap consumedNonces growth — drop oldest entries past the LRU cap.
|
|
if (consumedNonces.size > NONCE_LRU_CAP) {
|
|
const drop = consumedNonces.size - NONCE_LRU_CAP;
|
|
const it = consumedNonces.values();
|
|
for (let i = 0; i < drop; i++) consumedNonces.delete(it.next().value as string);
|
|
}
|
|
}
|
|
|
|
// POST /admin/api/issue-magic-link — agent-callable mint endpoint.
|
|
// Auth: Authorization: Bearer <bootstrapToken>. Returns one-time nonce.
|
|
app.post('/admin/api/issue-magic-link', express.json(), (req: Request, res: Response) => {
|
|
const auth = (req.headers.authorization || '') as string;
|
|
const m = auth.match(/^Bearer\s+(\S+)$/i);
|
|
if (!m) {
|
|
res.status(401).json({ error: 'Authorization: Bearer <bootstrap-token> required' });
|
|
return;
|
|
}
|
|
const tokenHash = createHash('sha256').update(m[1]).digest('hex');
|
|
if (!safeHexEqual(tokenHash, bootstrapHash)) {
|
|
res.status(401).json({ error: 'Invalid bootstrap token' });
|
|
return;
|
|
}
|
|
pruneExpiredNonces();
|
|
const nonce = randomBytes(32).toString('hex');
|
|
magicLinkNonces.set(nonce, Date.now() + NONCE_TTL_MS);
|
|
const baseUrl = publicUrl || `http://localhost:${port}`;
|
|
res.json({ url: `${baseUrl}/admin/auth/${nonce}`, expires_in: NONCE_TTL_MS / 1000 });
|
|
});
|
|
|
|
// GET /admin/auth/:nonce — single-use magic link redemption.
|
|
// Browser hits it, server validates the nonce (exists + unconsumed +
|
|
// unexpired), marks consumed, sets cookie, redirects to dashboard.
|
|
// Rate-limited at 10/min/IP to harden against DoS via bad-token loops.
|
|
app.get('/admin/auth/:token', adminAuthRateLimiter, (req: Request, res: Response) => {
|
|
const nonce = String(req.params.token ?? '');
|
|
pruneExpiredNonces();
|
|
|
|
const expiresAt = magicLinkNonces.get(nonce);
|
|
const isValid = !!nonce && !!expiresAt && expiresAt > Date.now() && !consumedNonces.has(nonce);
|
|
|
|
if (!isValid) {
|
|
res.status(401).send(`<!DOCTYPE html>
|
|
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>GBrain</title>
|
|
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0f;color:#e0e0e0;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
|
.box{max-width:400px;padding:32px;text-align:left}
|
|
.logo{font-size:28px;font-weight:600;margin-bottom:24px}
|
|
.msg{color:#888;font-size:14px;line-height:1.6;margin-bottom:20px}
|
|
.hint{background:rgba(136,170,255,0.08);border:1px solid rgba(136,170,255,0.2);border-radius:8px;padding:14px 16px;font-size:13px;line-height:1.5;color:#888}
|
|
.hint b{color:#e0e0e0}
|
|
.prompt{background:rgba(0,0,0,0.3);border-radius:6px;padding:8px 12px;margin-top:8px;font-family:monospace;font-size:12px;color:#88aaff}
|
|
</style></head><body><div class="box">
|
|
<div class="logo">GBrain</div>
|
|
<div class="msg">⚠️ This admin link has expired, was already used, or the server has restarted.</div>
|
|
<div class="hint"><b>Get a fresh link from your AI agent:</b>
|
|
<div class="prompt">“Give me the GBrain admin login link”</div>
|
|
</div></div></body></html>`);
|
|
return;
|
|
}
|
|
|
|
// Consume the nonce — it's single-use, second click will fail.
|
|
magicLinkNonces.delete(nonce);
|
|
consumedNonces.add(nonce);
|
|
|
|
const sessionId = randomBytes(32).toString('hex');
|
|
const sessionExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days for magic link
|
|
adminSessions.set(sessionId, sessionExpiresAt);
|
|
|
|
res.cookie('gbrain_admin', sessionId, adminCookie(req, 7 * 24 * 60 * 60 * 1000));
|
|
res.redirect('/admin/');
|
|
});
|
|
|
|
// Admin auth middleware
|
|
function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) {
|
|
const sessionId = (req.cookies as Record<string, string>)?.gbrain_admin;
|
|
if (!sessionId || !adminSessions.has(sessionId)) {
|
|
res.status(401).json({ error: 'Admin authentication required' });
|
|
return;
|
|
}
|
|
const expiresAt = adminSessions.get(sessionId)!;
|
|
if (Date.now() > expiresAt) {
|
|
adminSessions.delete(sessionId);
|
|
res.status(401).json({ error: 'Session expired' });
|
|
return;
|
|
}
|
|
next();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin API endpoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Sign-out-everywhere: nuke ALL active admin sessions in-memory. Every
|
|
// browser/tab fails its next request, gets 401, redirects to login.
|
|
// The bootstrap token itself is unaffected (still valid for new
|
|
// magic-link mints) — this only revokes existing cookie sessions.
|
|
app.post('/admin/api/sign-out-everywhere', requireAdmin, (_req: Request, res: Response) => {
|
|
const count = adminSessions.size;
|
|
adminSessions.clear();
|
|
res.json({ revoked_sessions: count });
|
|
});
|
|
|
|
app.get('/admin/api/agents', requireAdmin, async (_req: Request, res: Response) => {
|
|
try {
|
|
// Unified view: OAuth clients + legacy API keys
|
|
const oauthClients = await sql`
|
|
SELECT c.client_id as id, c.client_name as name, 'oauth' as auth_type,
|
|
c.grant_types, c.scope, c.created_at, c.token_ttl,
|
|
CASE WHEN c.deleted_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status,
|
|
(SELECT max(created_at) FROM mcp_request_log WHERE token_name = c.client_id) as last_used_at,
|
|
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = c.client_id) as total_requests,
|
|
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = c.client_id AND created_at > now() - interval '24 hours') as requests_today
|
|
FROM oauth_clients c ORDER BY c.created_at DESC
|
|
`;
|
|
const legacyKeys = await sql`
|
|
SELECT a.id, a.name, 'api_key' as auth_type,
|
|
'{"bearer"}' as grant_types, 'read write admin' as scope, a.created_at, null as token_ttl,
|
|
CASE WHEN a.revoked_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status,
|
|
a.last_used_at,
|
|
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = a.name) as total_requests,
|
|
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = a.name AND created_at > now() - interval '24 hours') as requests_today
|
|
FROM access_tokens a ORDER BY a.created_at DESC
|
|
`;
|
|
res.json([...oauthClients, ...legacyKeys]);
|
|
} catch (e) {
|
|
res.status(503).json({ error: 'service_unavailable' });
|
|
}
|
|
});
|
|
|
|
app.get('/admin/api/stats', requireAdmin, async (_req: Request, res: Response) => {
|
|
try {
|
|
const [clients] = await sql`SELECT count(*)::int as count FROM oauth_clients`;
|
|
const [tokens] = await sql`SELECT count(*)::int as count FROM oauth_tokens WHERE token_type = 'access' AND expires_at > ${Math.floor(Date.now() / 1000)}`;
|
|
const [requests] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE created_at > now() - interval '24 hours'`;
|
|
const [apiKeys] = await sql`SELECT count(*)::int as count FROM access_tokens WHERE revoked_at IS NULL`;
|
|
res.json({
|
|
connected_agents: (clients as any).count,
|
|
active_tokens: (tokens as any).count,
|
|
active_api_keys: (apiKeys as any).count,
|
|
requests_today: (requests as any).count,
|
|
});
|
|
} catch {
|
|
res.status(503).json({ error: 'service_unavailable' });
|
|
}
|
|
});
|
|
|
|
app.get('/admin/api/health-indicators', requireAdmin, async (_req: Request, res: Response) => {
|
|
try {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const [expiring] = await sql`SELECT count(*)::int as count FROM oauth_tokens WHERE token_type = 'access' AND expires_at BETWEEN ${now} AND ${now + 86400}`;
|
|
const [errors] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE status != 'success' AND created_at > now() - interval '24 hours'`;
|
|
const [total] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE created_at > now() - interval '24 hours'`;
|
|
const errorRate = (total as any).count > 0 ? ((errors as any).count / (total as any).count * 100).toFixed(1) : '0';
|
|
res.json({
|
|
expiring_soon: (expiring as any).count,
|
|
error_rate: `${errorRate}%`,
|
|
});
|
|
} catch {
|
|
res.status(503).json({ error: 'service_unavailable' });
|
|
}
|
|
});
|
|
|
|
// Full engine stats. v0.28.10 moved this off /health (which is now liveness
|
|
// only — see probeLiveness) so dashboards needing page_count / chunk_count
|
|
// / etc. authenticate as admin and call this endpoint. probeHealth races
|
|
// engine.getStats() against HEALTH_TIMEOUT_MS so a saturated pool returns
|
|
// 503 rather than hanging.
|
|
app.get('/admin/api/full-stats', requireAdmin, async (_req: Request, res: Response) => {
|
|
const result = await probeHealth(engine, config.engine || 'pglite', VERSION);
|
|
res.status(result.status).json(result.body);
|
|
});
|
|
|
|
app.get('/admin/api/requests', requireAdmin, async (req: Request, res: Response) => {
|
|
try {
|
|
const page = parseInt(req.query.page as string) || 1;
|
|
const limit = 50;
|
|
const offset = (page - 1) * limit;
|
|
const agent = req.query.agent as string;
|
|
const operation = req.query.operation as string;
|
|
const status = req.query.status as string;
|
|
|
|
// Dynamic filtering via postgres.js tagged-template fragments.
|
|
// Each filter expands to either `AND col = $N` (parameterized) or
|
|
// an empty fragment. `WHERE 1=1` lets us always have a WHERE clause
|
|
// and unconditionally append AND-prefixed fragments — no string
|
|
// interpolation, no manual escaping, no sql.unsafe.
|
|
const agentFilter = agent && agent !== 'all' ? sql`AND token_name = ${agent}` : sql``;
|
|
const opFilter = operation && operation !== 'all' ? sql`AND operation = ${operation}` : sql``;
|
|
const statusFilter = status && status !== 'all' ? sql`AND status = ${status}` : sql``;
|
|
|
|
const rows = await sql`
|
|
SELECT id, token_name, COALESCE(agent_name, token_name) as agent_name,
|
|
operation, latency_ms, status, params, error_message, created_at
|
|
FROM mcp_request_log
|
|
WHERE 1=1 ${agentFilter} ${opFilter} ${statusFilter}
|
|
ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset}
|
|
`;
|
|
const [countResult] = await sql`
|
|
SELECT count(*)::int as total FROM mcp_request_log
|
|
WHERE 1=1 ${agentFilter} ${opFilter} ${statusFilter}
|
|
`;
|
|
res.json({ rows, total: (countResult as any).total, page, pages: Math.ceil((countResult as any).total / limit) });
|
|
} catch {
|
|
res.status(503).json({ error: 'service_unavailable' });
|
|
}
|
|
});
|
|
|
|
// Legacy API keys (access_tokens table)
|
|
app.get('/admin/api/api-keys', requireAdmin, async (_req: Request, res: Response) => {
|
|
try {
|
|
const keys = await sql`
|
|
SELECT id, name, created_at, last_used_at,
|
|
CASE WHEN revoked_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status
|
|
FROM access_tokens ORDER BY created_at DESC
|
|
`;
|
|
res.json(keys);
|
|
} catch (e) {
|
|
res.status(503).json({ error: 'service_unavailable' });
|
|
}
|
|
});
|
|
|
|
app.post('/admin/api/api-keys', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
|
try {
|
|
const { name } = req.body;
|
|
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
|
const { generateToken, hashToken } = await import('../core/utils.ts');
|
|
const token = generateToken('gbrain_');
|
|
const hash = hashToken(token);
|
|
const id = (await import('crypto')).randomUUID();
|
|
await sql`INSERT INTO access_tokens (id, name, token_hash) VALUES (${id}, ${name}, ${hash})`;
|
|
res.json({ name, token, id });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e instanceof Error ? e.message : 'Failed to create API key' });
|
|
}
|
|
});
|
|
|
|
app.post('/admin/api/api-keys/revoke', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
|
try {
|
|
const { name } = req.body;
|
|
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
|
await sql`UPDATE access_tokens SET revoked_at = now() WHERE name = ${name} AND revoked_at IS NULL`;
|
|
res.json({ revoked: true });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e instanceof Error ? e.message : 'Revoke failed' });
|
|
}
|
|
});
|
|
|
|
// Register client from admin dashboard
|
|
app.post('/admin/api/register-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
|
try {
|
|
const { name, scopes, tokenTtl } = req.body;
|
|
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
|
const result = await oauthProvider.registerClientManual(
|
|
name, ['client_credentials'], scopes || 'read', [],
|
|
);
|
|
// Set per-client TTL if specified
|
|
if (tokenTtl && Number(tokenTtl) > 0) {
|
|
await sql`UPDATE oauth_clients SET token_ttl = ${Number(tokenTtl)} WHERE client_id = ${result.clientId}`;
|
|
}
|
|
res.json({ ...result, tokenTtl: tokenTtl ? Number(tokenTtl) : null });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e instanceof Error ? e.message : 'Registration failed' });
|
|
}
|
|
});
|
|
|
|
// Update client TTL
|
|
app.post('/admin/api/update-client-ttl', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
|
try {
|
|
const { clientId, tokenTtl } = req.body;
|
|
if (!clientId) { res.status(400).json({ error: 'clientId required' }); return; }
|
|
const ttl = tokenTtl === null || tokenTtl === 0 ? null : Number(tokenTtl);
|
|
await sql`UPDATE oauth_clients SET token_ttl = ${ttl} WHERE client_id = ${clientId}`;
|
|
res.json({ updated: true, tokenTtl: ttl });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e instanceof Error ? e.message : 'Update failed' });
|
|
}
|
|
});
|
|
|
|
// Revoke OAuth client
|
|
app.post('/admin/api/revoke-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
|
try {
|
|
const { clientId } = req.body;
|
|
if (!clientId) { res.status(400).json({ error: 'clientId required' }); return; }
|
|
// Soft-delete the client
|
|
await sql`UPDATE oauth_clients SET deleted_at = now() WHERE client_id = ${clientId} AND deleted_at IS NULL`;
|
|
// Revoke all active tokens for this client
|
|
await sql`DELETE FROM oauth_tokens WHERE client_id = ${clientId}`;
|
|
res.json({ revoked: true });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e instanceof Error ? e.message : 'Revoke failed' });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSE live activity feed
|
|
// ---------------------------------------------------------------------------
|
|
app.get('/admin/events', requireAdmin, (req: Request, res: Response) => {
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.flushHeaders();
|
|
|
|
sseClients.add(res);
|
|
req.on('close', () => sseClients.delete(res));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin SPA static files
|
|
// ---------------------------------------------------------------------------
|
|
// Serve from admin/dist if it exists (development), otherwise embedded assets
|
|
const path = await import('path');
|
|
const fs = await import('fs');
|
|
const adminDistPath = path.join(process.cwd(), 'admin', 'dist');
|
|
if (fs.existsSync(adminDistPath)) {
|
|
app.use('/admin', express.static(adminDistPath));
|
|
// SPA fallback: serve index.html for all unmatched /admin/* routes
|
|
app.get('/admin/{*path}', (req: Request, res: Response, next: NextFunction) => {
|
|
// Skip API and events routes
|
|
if (req.path.startsWith('/admin/api/') || req.path === '/admin/events' || req.path === '/admin/login') {
|
|
return next();
|
|
}
|
|
res.sendFile(path.join(adminDistPath, 'index.html'));
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP tool calls (bearer auth + scope enforcement)
|
|
// ---------------------------------------------------------------------------
|
|
const mcpOperations = operations.filter(op => !op.localOnly);
|
|
|
|
app.post('/mcp', requireBearerAuth({ verifier: oauthProvider }), async (req: Request, res: Response) => {
|
|
const startTime = Date.now();
|
|
const authInfo = (req as any).auth as AuthInfo;
|
|
|
|
// Human-readable agent name is now threaded through AuthInfo by
|
|
// verifyAccessToken (which JOINs oauth_clients in its existing token
|
|
// SELECT). No per-request DB roundtrip needed. Falls back to clientId
|
|
// for legacy tokens or when the JOIN row's client_name is NULL.
|
|
const agentName = authInfo.clientName ?? authInfo.clientId;
|
|
|
|
// Create a fresh MCP server per request (stateless)
|
|
const server = new Server(
|
|
{ name: 'gbrain', version: VERSION },
|
|
{ capabilities: { tools: {} } },
|
|
);
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
// v0.28.10: log every JSON-RPC method, not just successful tools/call.
|
|
// Pre-fix, /admin/api/requests showed nothing for clients that only
|
|
// ever called tools/list, and the v0.26.3 persistence regression test
|
|
// asserting >= 2 rows after tools/list + tools/call was unreachable.
|
|
const latency = Date.now() - startTime;
|
|
try {
|
|
await sql`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params)
|
|
VALUES (${authInfo.clientId}, ${agentName}, ${'tools/list'}, ${latency}, ${'success'}, ${null})`;
|
|
} catch { /* best effort */ }
|
|
broadcastEvent({
|
|
agent: agentName,
|
|
operation: 'tools/list',
|
|
scopes: authInfo.scopes.join(','),
|
|
latency_ms: latency,
|
|
status: 'success',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
return {
|
|
tools: mcpOperations.map(op => ({
|
|
name: op.name,
|
|
description: op.description,
|
|
inputSchema: {
|
|
type: 'object' as const,
|
|
properties: Object.fromEntries(
|
|
Object.entries(op.params).map(([k, v]) => [k, {
|
|
type: v.type,
|
|
description: v.description,
|
|
...(v.enum ? { enum: v.enum } : {}),
|
|
...(v.default !== undefined ? { default: v.default } : {}),
|
|
}]),
|
|
),
|
|
required: Object.entries(op.params).filter(([, v]) => v.required).map(([k]) => k),
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
const { name, arguments: params } = request.params;
|
|
const op = mcpOperations.find(o => o.name === name);
|
|
if (!op) {
|
|
// v0.28.10: persist unknown-op attempts. Operators investigating
|
|
// misbehaving agents need to see the full attempt log, not just
|
|
// valid-op success/error.
|
|
const latency = Date.now() - startTime;
|
|
try {
|
|
await sql`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params, error_message)
|
|
VALUES (${authInfo.clientId}, ${agentName}, ${name}, ${latency}, ${'error'}, ${null}, ${`unknown_operation: ${name}`})`;
|
|
} catch { /* best effort */ }
|
|
broadcastEvent({
|
|
agent: agentName,
|
|
operation: name,
|
|
scopes: authInfo.scopes.join(','),
|
|
latency_ms: latency,
|
|
status: 'error',
|
|
error: { code: 'unknown_operation', message: `Unknown: ${name}` },
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown_operation', message: `Unknown: ${name}` }) }], isError: true };
|
|
}
|
|
|
|
// Scope enforcement (v0.28: hasScope replaces exact-string-match so
|
|
// admin tokens satisfy any scope, write satisfies read, and the new
|
|
// sources_admin / users_admin scopes resolve through the same
|
|
// hierarchy. Plain string includes() at this site would have made
|
|
// sources_admin tokens look like they couldn't even read.)
|
|
const requiredScope = op.scope || 'read';
|
|
if (!hasScope(authInfo.scopes, requiredScope)) {
|
|
// v0.28.10: persist scope-rejected attempts. Same operator-visibility
|
|
// motivation as the unknown-op path — and it makes the v0.26.3
|
|
// persistence regression test reliable across both rejection paths.
|
|
const latency = Date.now() - startTime;
|
|
try {
|
|
await sql`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params, error_message)
|
|
VALUES (${authInfo.clientId}, ${agentName}, ${name}, ${latency}, ${'error'}, ${null}, ${`insufficient_scope: requires '${requiredScope}'`})`;
|
|
} catch { /* best effort */ }
|
|
broadcastEvent({
|
|
agent: agentName,
|
|
operation: name,
|
|
scopes: authInfo.scopes.join(','),
|
|
latency_ms: latency,
|
|
status: 'error',
|
|
error: { code: 'insufficient_scope', message: `requires '${requiredScope}'` },
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
error: 'insufficient_scope',
|
|
message: `Operation ${name} requires '${requiredScope}' scope`,
|
|
your_scopes: authInfo.scopes,
|
|
}),
|
|
}],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const ctx: OperationContext = {
|
|
engine,
|
|
config,
|
|
logger: {
|
|
info: (msg: string) => console.error(`[INFO] ${msg}`),
|
|
warn: (msg: string) => console.error(`[WARN] ${msg}`),
|
|
error: (msg: string) => console.error(`[ERROR] ${msg}`),
|
|
},
|
|
dryRun: !!(params?.dry_run),
|
|
// F7: HTTP MCP is the untrusted/agent-facing transport. Stdio MCP at
|
|
// src/mcp/dispatch.ts:61 sets this; the inlined HTTP context-builder
|
|
// forgot it for several releases, which let HTTP MCP callers with a
|
|
// read+write token submit `shell` jobs and execute arbitrary commands
|
|
// on the host (RCE). The fail-closed contract in operations.ts is the
|
|
// belt; this is the suspenders.
|
|
remote: true,
|
|
auth: authInfo,
|
|
};
|
|
|
|
// F8: redact request payload by default (declared keys only via the
|
|
// op's `params` allow-list; values + attacker-controlled key names
|
|
// never written to mcp_request_log or the SSE feed). --log-full-params
|
|
// bypasses this for operators debugging on their own laptop, with the
|
|
// startup warning printed earlier.
|
|
const safeParamsSummary = summarizeMcpParams(name, params);
|
|
const logParams = logFullParams
|
|
? (params ? JSON.stringify(params) : null)
|
|
: (safeParamsSummary ? JSON.stringify(safeParamsSummary) : null);
|
|
const broadcastParams = logFullParams ? (params || {}) : safeParamsSummary;
|
|
|
|
try {
|
|
const result = await op.handler(ctx, (params || {}) as Record<string, unknown>);
|
|
const latency = Date.now() - startTime;
|
|
|
|
try {
|
|
await sql`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params)
|
|
VALUES (${authInfo.clientId}, ${agentName}, ${name}, ${latency}, ${'success'}, ${logParams})`;
|
|
} catch { /* best effort */ }
|
|
|
|
broadcastEvent({
|
|
agent: agentName,
|
|
operation: name,
|
|
params: broadcastParams,
|
|
scopes: authInfo.scopes.join(','),
|
|
latency_ms: latency,
|
|
status: 'success',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
} catch (e) {
|
|
const latency = Date.now() - startTime;
|
|
// F15: unify error envelope. Both OperationError and unexpected
|
|
// exceptions go through src/core/errors.ts so clients see a single
|
|
// shape ({class, code, message, hint}). Pre-fix, OperationError
|
|
// serialized via e.toJSON() and other exceptions used a hand-rolled
|
|
// {error, message} envelope — a client couldn't pattern-match
|
|
// reliably across the two.
|
|
const errorPayload = e instanceof OperationError
|
|
? buildError({
|
|
class: 'OperationError',
|
|
code: e.code,
|
|
message: e.message,
|
|
hint: e.suggestion,
|
|
docs_url: e.docs,
|
|
})
|
|
: serializeError(e);
|
|
const errMsg = errorPayload.message;
|
|
try {
|
|
await sql`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params, error_message)
|
|
VALUES (${authInfo.clientId}, ${agentName}, ${name}, ${latency}, ${'error'}, ${logParams}, ${errMsg})`;
|
|
} catch { /* best effort */ }
|
|
|
|
broadcastEvent({
|
|
agent: agentName,
|
|
operation: name,
|
|
params: broadcastParams,
|
|
scopes: authInfo.scopes.join(','),
|
|
latency_ms: latency,
|
|
status: 'error',
|
|
error: errorPayload,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
return { content: [{ type: 'text', text: JSON.stringify({ error: errorPayload }) }], isError: true };
|
|
}
|
|
});
|
|
|
|
// F14: wrap transport setup + handleRequest in try/catch. Without this,
|
|
// an SDK-level throw (e.g., schema parse failure on a malformed request)
|
|
// propagates to express's default error handler, which renders an HTML
|
|
// error page — clients expecting JSON-RPC envelopes break. On
|
|
// !res.headersSent we emit a minimal JSON 500 so the client at least
|
|
// gets parseable JSON back.
|
|
try {
|
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined as any });
|
|
await server.connect(transport);
|
|
await transport.handleRequest(req, res, req.body);
|
|
} catch (e) {
|
|
console.error('MCP request handler error:', e instanceof Error ? e.message : e);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
error: 'internal_error',
|
|
message: e instanceof Error ? e.message : 'Unknown error',
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Start server
|
|
// ---------------------------------------------------------------------------
|
|
const clientCount = await sql`SELECT count(*)::int as count FROM oauth_clients`;
|
|
|
|
app.listen(port, () => {
|
|
console.error(`
|
|
╔══════════════════════════════════════════════════════╗
|
|
║ GBrain MCP Server v${VERSION.padEnd(37)}║
|
|
╠══════════════════════════════════════════════════════╣
|
|
║ Port: ${String(port).padEnd(40)}║
|
|
║ Engine: ${(config.engine || 'pglite').padEnd(40)}║
|
|
║ Issuer: ${issuerUrl.origin.padEnd(40)}║
|
|
║ Clients: ${String((clientCount[0] as any).count).padEnd(40)}║
|
|
║ DCR: ${(enableDcr ? 'enabled' : 'disabled').padEnd(40)}║
|
|
║ Token TTL: ${(tokenTtl + 's').padEnd(40)}║
|
|
╠══════════════════════════════════════════════════════╣
|
|
║ Admin: http://localhost:${port}/admin${' '.repeat(Math.max(0, 19 - String(port).length))}║
|
|
║ MCP: http://localhost:${port}/mcp${' '.repeat(Math.max(0, 21 - String(port).length))}║
|
|
║ Health: http://localhost:${port}/health${' '.repeat(Math.max(0, 18 - String(port).length))}║
|
|
╠══════════════════════════════════════════════════════╣
|
|
║ Admin Token (paste into /admin login): ║
|
|
║ ${bootstrapToken.substring(0, 50)} ║
|
|
║ ${bootstrapToken.substring(50).padEnd(50)} ║
|
|
╚══════════════════════════════════════════════════════╝
|
|
`);
|
|
});
|
|
}
|