Files
gbrain/src/commands/serve-http.ts
T
2ea5b71177 v0.28.1 fix: zombie process accumulation + health endpoint timeout (#637)
* fix: zombie process accumulation + health endpoint timeout

Three fixes for cascading failure mode in long-running deployments:

1. cli.ts: Install SIGCHLD handler to reap zombie children. Bun (like Node)
   only auto-reaps when a handler is registered. Without this, child processes
   spawned by the worker (embed batches, shell jobs, sub-agents) become zombies
   when they exit, accumulating in the PID table.

2. serve-http.ts: Add 5s timeout to /health endpoint's getStats() call.
   When the DB connection pool is saturated (e.g., from zombie processes
   holding phantom connections), getStats() hangs indefinitely, making the
   server appear dead to health checks even though it's running.

3. worker.ts: Call engine.disconnect() in the finally block after draining
   in-flight jobs. Releases PgBouncer connection slots immediately on shutdown
   rather than waiting for TCP keepalive expiry.

4. supervisor.ts + autopilot.ts: Auto-detect tini on PATH and wrap the
   spawned worker with it. Belt-and-suspenders with the SIGCHLD handler —
   tini catches children spawned by native addons that bypass the JS event
   loop. Zero-config: works when tini is installed, silently skips when not.

* refactor(zombie-reap): extract idempotent SIGCHLD installer module

Extract the inline SIGCHLD handler from cli.ts into a small dedicated
module so it's testable directly without importing cli.ts (which invokes
main() at module load — incompatible with bun:test imports).

The new installSigchldHandler() uses a named module-level handler +
includes() check to dedupe across hot-import scenarios. EventEmitter does
NOT dedupe listeners by reference, so without this guard a re-import of
zombie-reap.ts would accumulate handlers.

_uninstallSigchldHandlerForTests() is the test-only escape hatch so
test/zombie-reap.test.ts's afterAll can prevent cross-file listener
accumulation in the parallel shard process — codex review #6 noted that
mutating global process signal listeners in parallel pools is a leak class
the isolation lint doesn't protect against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(spawn-helpers): extract detectTini + buildSpawnInvocation; DRY-consolidate supervisor + autopilot

Pulls the duplicated tini detection + (cmd, args) composition out of
src/core/minions/supervisor.ts and src/commands/autopilot.ts into a single
src/core/minions/spawn-helpers.ts module that both consume.

Side effects:
- Autopilot now resolves tini ONCE at startup instead of shelling out via
  execSync('which tini') on every worker respawn (every restart-after-crash
  path lost ~1ms + a fork to /usr/bin/which).
- detectTini() passes env: process.env explicitly to execFileSync. Bun
  snapshots env at startup; without this, runtime PATH mutations (in tests
  via withEnv, or in any prod code that ever changes PATH) are invisible
  to `which`. Tiny correctness fix that also makes the test work.
- MinionSupervisor gains an `isTiniDetected` read-only accessor so
  test/supervisor-tini.test.ts can assert the constructor wired tini
  correctly without exposing the resolved path or needing to spawn the
  full lifecycle. The existing worker_spawned event payload still carries
  {tini: true} for runtime observability (per codex review #5).

Test coverage:
- test/spawn-helpers.test.ts: pure function tests for both helpers
  (with-tini / without-tini / empty-args / detectTini smoke)
- test/supervisor-tini.test.ts: constructor wiring with PATH stripped
  vs. PATH containing a fake-tini script in a tmpdir

Both files are *.test.ts (parallel-safe) and pass scripts/check-test-isolation.sh
without new allow-list entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(serve-http): extract probeHealth() + drop /health timeout 5s -> 3s

Three changes folded into one commit because they touch the same route
handler and would conflict if split:

1. Extract probeHealth(engine, engineName, version, timeoutMs) as a pure
   exported function. Route handler becomes one branchless line:
     res.status(result.status).json(result.body)
   This makes the timeout / db-error / happy paths unit-testable directly
   without an Express test client and without a hardcoded 5000 literal
   inside the route closure.

2. Export HEALTH_TIMEOUT_MS = 3000 (was inline 5000). Fly.io default
   health-check timeout is 5s; at 5s exact, the orchestrator may record
   a request as a timeout instead of getting the 503 (race). 3s gives
   2s of headroom for TCP, response framing, and clock skew. The
   DB-pool-saturation signal still surfaces; we just stop racing the
   orchestrator deadline.

3. The route handler shape change (4 try/catch lines -> 1 wrapper line)
   keeps response semantics identical for all three paths.

Test coverage:
- test/serve-http-health.test.ts: 4 cases (happy / timeout / db-error /
  exported constant). Calls probeHealth directly with mock engines whose
  getStats() resolves / rejects / hangs forever. Wall-clock per test
  bounded by passing timeoutMs: 100.
- Existing test/e2e/serve-http-oauth.test.ts /health happy-path case
  still covers the Express wiring (one-line route handler is identical
  Express plumbing for 200 and 503).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(worker): log engine.disconnect errors during shutdown instead of swallowing

Replace bare \`try { await this.engine.disconnect(); } catch {}\` with
\`catch (e) { console.error('[worker] disconnect failed during shutdown:', e); }\`.

Why: shutdown is best-effort, but the original silent catch was exactly
the bug class the v0.26.9 D14 direction (isUndefinedColumnError swap-in
on oauth-provider.ts) was created to surface. If a future regression
breaks pool teardown so disconnect rejects, we'll never know without an
audit log line. Two-character diff to the catch, no behavior change for
the happy path.

Test coverage in test/worker-shutdown-disconnect.test.ts:
- Happy path: disconnect spy called once during shutdown (intercept-only,
  not call-through, so the shared engine stays connected for the next
  test in the file).
- Error path: disconnect throws, error is logged with the
  \`[worker] disconnect failed during shutdown:\` prefix and the bare
  Error as second arg, and start() still resolves (no rethrow).

Spy via spyOn() on the engine instance — object-level, not module-level,
so R2 of scripts/check-test-isolation.sh (which forbids module-level mocks
in non-serial unit tests) is satisfied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): real-binary zombie reaping reproduction (DATABASE_URL-gated)

Spawns the gbrain CLI as \`bun run src/cli.ts jobs work --concurrency 1\`
against a real Postgres with GBRAIN_ALLOW_SHELL_JOBS=1, submits a shell
job from the CLI side (remote: false, bypasses the v0.26.9 RCE gate),
captures the worker's shell child PID from the job result, sleeps 300ms,
then \`ps -o stat= -p <pid>\` to assert the process is NOT lingering as a
zombie (Z state).

Why this shape:
- \`gbrain serve --http\` was the original plan but doesn't start a worker
  (only the MCP server) AND submit_job over MCP carries remote: true,
  which rejects shell at operations.ts:1391 (the v0.26.9 RCE-fix gate).
  jobs work + CLI-side submit is the only architecture that boots through
  cli.ts (so installSigchldHandler() actually runs) and lets a shell job
  execute.
- \`shell\` requires absolute cwd (shell.ts:53). Payload includes cwd: '/tmp'.
- ps check is run while the worker is STILL ALIVE (no PID-recycle race —
  worker holds the process tree, so the captured PID is meaningful).

Negative control (manual, NOT in CI, documented in test header):
  Comment out installSigchldHandler() in src/cli.ts -> rebuild -> re-run
  -> expect stat=Z. Re-enable -> expect stat empty (process gone, reaped).
  Demonstrates the test catches the regression class without paying CI
  cost for a separate broken-build target.

Skips:
- DATABASE_URL not set (matches existing E2E pattern in helpers.ts)
- Windows (POSIX-only; tini and SIGCHLD don't exist there)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(postgres-engine): make disconnect() idempotent so it doesn't clobber the module-level singleton

PostgresEngine.disconnect() was non-idempotent: after the first call ended
\`_sql\` and set it to null, a second call fell through to the \`else\` branch
that calls db.disconnect() — which clears the GLOBAL module-level
connection used by helpers.ts, the CLI main path, and every test that
hadn't opted into a private pool.

This bit minions-shell.test.ts and the entire downstream E2E suite when
commit 671ef099 (in this branch) added engine.disconnect() to
MinionWorker.start()'s finally block. Tests that did:

  await worker.start();          // worker disconnects (was the new behavior)
  await engine.disconnect();     // test cleanup; pre-fix fell through
                                  // to db.disconnect() and killed
                                  // the global connection

…would silently kill the helpers.ts singleton, and the next test in the
file would fail in its beforeEach with "No database connection".

Fix: track \`_connectionStyle\` ('instance' | 'module' | null) on the engine
and only call db.disconnect() when this engine actually owns the global.
After ending an instance-pool, _connectionStyle stays 'instance' so a
second disconnect() is a no-op rather than a side-effect.

Test coverage: test/e2e/postgres-engine-disconnect-idempotency.test.ts
pins both contracts:
  - instance-pool engine: second disconnect MUST NOT clobber the module
    singleton (the bug above).
  - module-singleton engine: second disconnect is a no-op (resolves
    cleanly, no throw).

Required for: minions-shell.test.ts to keep passing alongside the worker
changes on this branch. Discovered during E2E sweep after the unit-test
green light. Commit 7 in this branch then walks back the worker-side
disconnect entirely (engine ownership belongs to the CLI handler) but
this idempotency fix stays in place as a defense-in-depth guard against
any future code calling disconnect twice on the same engine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: move engine.disconnect() from worker.start() to gbrain jobs work CLI handler (engine ownership)

Commit 671ef099 (the original fix in this branch) put
\`await this.engine.disconnect()\` inside MinionWorker.start()'s finally
block to free PgBouncer pool slots immediately on shutdown. That was the
right intent on the wrong layer: the worker doesn't own the engine, the
CLI handler that creates the engine does.

The mismatched ownership broke every test that shares a single engine
across multiple worker.start() / worker.stop() cycles:

  - test/e2e/minions-shell-pglite.test.ts → shared PGLite engine, second
    test failed with "PGLite not connected"
  - test/e2e/worker-abort-recovery.test.ts → 3 tests, same shape
  - test/e2e/minions-shell.test.ts → 3 Postgres tests broken by the
    second-disconnect-clobbers-global-singleton symptom (commit 6 of
    this branch fixed the underlying engine non-idempotency, but the
    worker-disconnect call was still wrong on its own)

Fix:
  - worker.ts: remove the engine.disconnect() call. Add a comment
    documenting WHY the worker doesn't disconnect (ownership invariant)
    so a future contributor doesn't put it back.
  - src/commands/jobs.ts case 'work': wrap worker.start() in a
    try/finally that calls engine.disconnect() on shutdown. The CLI
    created the engine (line 631 area), so the CLI disposes of it.
    Disconnect failure logs to stderr with the
    "[gbrain jobs work] engine disconnect failed during shutdown:" prefix
    rather than the bare \`catch {}\` of earlier waves — matches the
    v0.26.9 D14 direction of preferring loud-but-best-effort over silent.

Test:
  - test/worker-shutdown-disconnect.test.ts now pins the inverse
    invariant: worker.start() MUST NOT call engine.disconnect(), and
    the engine MUST remain queryable after start() returns. Two tests,
    instance-level spy, parallel-safe (no module mocking).

End state: gbrain jobs work in production still frees pool slots
immediately on shutdown (intent of 671ef099 preserved), tests that share
an engine don't break (regression class fixed), and the engine ownership
invariant is now codified in code AND in the test suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clearTimeout in probeHealth race + platform guard SIGCHLD on Windows

Two adversarial-review auto-fixes from /ship's pre-landing review pass.
Both reviewers (Claude adversarial subagent + Codex adversarial) flagged
the timer leak independently; Codex additionally caught the Windows
crash risk.

1. probeHealth race timer leak (serve-http.ts):
   `Promise.race([getStats(), setTimeout(...)])` doesn't cancel the loser.
   Without `clearTimeout`, every fast /health request leaves a 3s pending
   timer in the event loop until it fires. Under sustained probe rates
   (Fly.io polls every ~10s, orchestrator load balancers can be much
   tighter), this builds a rolling backlog of timers and avoidable event
   loop wakeups in the hottest endpoint. Capture the timer handle, clear
   it in a `finally` block. No-op when the timer already fired.

2. SIGCHLD platform guard (zombie-reap.ts):
   SIGCHLD is POSIX-only. On Windows, `process.on('SIGCHLD', ...)` throws
   ENOTSUP because Windows doesn't have signals. Bun behaves the same.
   Without this guard, any future Windows port of a gbrain CLI tool
   would crash at boot before main() even runs. The zombie-reaping fix
   is itself POSIX-only (tini, ps, /proc), so the guard is consistent
   with the platform's capability set.

NOT in this commit (intentionally out of scope):
- Cancelling engine.getStats() when /health times out. Both reviewers
  noted this would need AbortController support in the engine layer
  which doesn't exist yet. The 503 timeout already improves on master's
  hang behavior; full cancellation is a follow-up.
- Switching /health to a lighter probe (SELECT 1 instead of count(*)
  across 6 tables). Pre-existing behavior; refactoring the probe shape
  is wider blast radius than this branch's zombie-reaping scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.28.1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update CLAUDE.md for v0.28.1 zombie reaping + health + engine ownership

Add v0.28.1 file annotations covering:
- src/core/zombie-reap.ts (new) — Layer 1 SIGCHLD reaper module
- src/core/minions/spawn-helpers.ts (new) — pure detectTini + buildSpawnInvocation helpers
- src/core/minions/worker.ts — engine-ownership invariant (no engine.disconnect)
- src/core/minions/supervisor.ts — consumes spawn-helpers, exposes isTiniDetected
- src/commands/serve-http.ts — probeHealth() + HEALTH_TIMEOUT_MS = 3000
- src/commands/jobs.ts — case 'work' owns engine lifecycle via try/finally
- src/commands/autopilot.ts — resolves tini once at startup
- src/core/postgres-engine.ts — disconnect() is idempotent via _connectionStyle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:54:06 -07:00

881 lines
40 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 { 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);
}
}
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,
scopesSupported: ['read', 'write', 'admin'],
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
// ---------------------------------------------------------------------------
app.get('/health', async (_req, res) => {
const result = await probeHealth(engine, 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">&ldquo;Give me the GBrain admin login link&rdquo;</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' });
}
});
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 () => ({
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) {
return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown_operation', message: `Unknown: ${name}` }) }] };
}
// Scope enforcement
const requiredScope = op.scope || 'read';
if (!authInfo.scopes.includes(requiredScope)) {
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)}
╚══════════════════════════════════════════════════════╝
`);
});
}