Files
gbrain/docs/mcp/DEPLOY.md
T
cb02932388 v0.26.9 fix(oauth): RFC 6749 hardening + close HTTP MCP shell-job RCE (#628)
* fix(mcp): close HTTP MCP shell-job RCE + tighten remote contract

The HTTP MCP transport in serve-http.ts inlined its own OperationContext
literal and forgot to set `remote: true`. With the field undefined at the
operations.ts protected-job-name guard (line 1391), an HTTP MCP caller
holding a write-scoped OAuth token could submit `submit_job {name: "shell"}`
and execute arbitrary commands on the gbrain host (RCE-class).

Two-layer fix:

1. F7 — explicit `remote: true` on the inlined /mcp OperationContext.
   Stdio MCP at src/mcp/dispatch.ts:61 already set this; the HTTP path
   was the regression.

2. F7b — fail-closed contract on the four ctx.remote consumer sites in
   operations.ts (auto-link skip, telemetry x2, protected-job guard).
   The protected-job guard flips from `if (ctx.remote && ...)` to
   `if (ctx.remote !== false && ...)` and the trusted-marker site flips
   from `!ctx.remote && ...` to `ctx.remote === false && ...`. Anything
   that isn't strictly `false` now treats the caller as remote/untrusted.

3. D12 — `OperationContext.remote` becomes REQUIRED in the TypeScript
   type. The compiler now catches future transports that forget the field.
   The runtime fail-closed defaults are belt+suspenders for any caller
   that bypasses the type via `as` cast or `Partial<>` spread.

Tests:

- New `test/trust-boundary-contract.test.ts` (4 cases) pins the
  fail-closed semantics: undefined-via-cast rejects, remote=true rejects,
  remote=false allowed (only path that escalates protected-name jobs).

- `test/e2e/serve-http-oauth.test.ts` adds 2 cases asserting HTTP MCP
  cannot submit `shell` or `subagent` jobs even with read+write scope.

- `test/e2e/graph-quality.test.ts` adds the now-required `remote: false`
  to its fixture (e2e graph quality simulates local-CLI writes).

Verification: bun test -> 3742 pass / 0 fail. typecheck clean.

Thanks to @ElectricSheepIO on X for the security review that surfaced
this trust-boundary regression.

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

* fix(oauth): RFC 6749 hardening + serve-http defense in depth

OAuth provider hardening pass that brings the provider into RFC compliance
on auth code, refresh token, and revocation flows, and tightens the
serve-http surface around request logging and admin cookies.

Provider (src/core/oauth-provider.ts):

- F1: bind client_id atomically into the auth code DELETE WHERE clause for
  exchangeAuthorizationCode + challengeForAuthorizationCode. Previous
  pattern (DELETE...RETURNING then post-hoc client compare) burned codes
  on the wrong-client path so the legitimate client could not retry.
  RFC 6749 §10.5.

- F2: same atomic predicate on exchangeRefreshToken. The pre-fix shape
  defeated RFC 6749 §10.4's stolen-token detection by letting attacker +
  victim both succeed.

- F3: refresh token rejects requested scopes that are not a subset of the
  ORIGINAL grant on the row. Codex C9: subset is checked against the
  recorded grant, not the client's currently-allowed scopes (which can
  expand later); omitted scope inherits the original verbatim and stays
  distinct from explicit-empty. RFC 6749 §6.

- F4: revokeToken adds AND client_id to the DELETE so a client cannot
  revoke another client's tokens by guessing the hash. RFC 7009 §2.1.

- F5: deleted_at and token_ttl column probes use a new
  isUndefinedColumnError helper (extracted to src/core/utils.ts per D14)
  that matches SQLSTATE 42703 or column-name-in-message. Bare catch{}
  used to swallow lock timeouts, network blips, and auth failures as
  "column missing" — fail-open posture in a security path.

- F6: sweepExpiredTokens uses RETURNING 1 + array length. Pre-fix
  (result as any).count returned 0 on at least one engine even when
  rows were deleted, and codes were never counted.

- F7c: NEW finding eva-brain missed. exchangeAuthorizationCode now folds
  redirect_uri into the atomic DELETE predicate when the parameter is
  provided. Stored on /authorize, never compared on /token before this
  commit. RFC 6749 §4.1.3 violation. Back-compat: when caller omits the
  parameter the predicate is skipped, preserving SDK consumers that
  haven't adopted the parameter yet.

- F12 (cleanup, not security): dcrDisabled constructor option replaces
  the prior monkey-patch of _clientsStore in serve-http.ts. The SDK's
  mcpAuthRouter only wires up /register when the store exposes
  registerClient, so omitting the method via the constructor is
  sufficient. Reframed as cleanup per codex C10 — the monkey-patch
  happened before mcpAuthRouter ran, so the prior shape did not have
  a real security regression to claim.

Dispatch (src/mcp/dispatch.ts):

- F8: new summarizeMcpParams(opName, params) intersects submitted keys
  against the operation's declared params allow-list. Returns
  {redacted, kind, declared_keys, unknown_key_count, approx_bytes}.
  Closes the codex C8 leak: a naive "dump all submitted keys" summary
  still echoed attacker-controlled key names like
  put_page {"wiki/people/sensitive_name": "..."} into mcp_request_log
  + the SSE feed. Allow-list pattern keeps debug visibility on declared
  keys while counting unknowns without naming them.

Serve-http (src/commands/serve-http.ts) + serve (src/commands/serve.ts):

- F8 wiring: mcp_request_log + SSE broadcast routed through
  summarizeMcpParams by default. New --log-full-params flag bypasses
  redaction with a loud stderr warning at startup. Default privacy-
  positive; flag is the documented escape hatch for self-hosted
  operators debugging on their own laptop.

- F9: admin cookies set Secure when req.secure OR issuerUrl.protocol
  is https. Cloudflare-tunnel + reverse-proxy deployments where the
  inside-tunnel hop looks like http but the public URL is https now
  tag cookies correctly.

- F10: bound magicLinkNonces with NONCE_LRU_CAP. Previously only the
  consumed-nonces map was capped; an attacker (or misbehaving agent)
  with the bootstrap token could mint nonces faster than they expired
  and grow the live store unbounded.

- F12: dcrDisabled flows through to the provider constructor instead of
  monkey-patching _clientsStore after construction.

- F14: try/catch wraps StreamableHTTPServerTransport setup +
  handleRequest. SDK-level throws no longer fall through to express's
  default HTML error page; clients expecting JSON-RPC envelopes get a
  JSON 500 instead.

- F15: error envelope unified via buildError + serializeError from
  src/core/errors.ts. OperationError and unexpected exceptions both
  emit the same {class, code, message, hint} shape so clients can
  pattern-match a single envelope.

Tests:

- test/oauth.test.ts adds 11 cases:
  * F1+F2 wrong-client cannot consume / read PKCE / burn refresh,
    paired with owner-still-redeems atomically afterward (codex D6 —
    proves the predicate doesn't burn the row on attacker attempts).
  * F3 refresh scope subset enforced.
  * F4 wrong-client cannot revoke.
  * F5 non-schema SQL not swallowed by client_credentials soft-delete probe.
  * F6 sweepExpiredTokens returns count > 0 after deleting rows.
  * F7c redirect_uri match succeeds, mismatch rejects, omitted preserves
    back-compat for callers that don't pass the parameter.
  * F12 dcrDisabled constructor option exposes only getClient,
    registerClientManual still works.

- test/mcp-dispatch-summarize.test.ts (NEW, 6 cases): pins the F8
  privacy invariants. The codex-C8 attacker-key-name probe asserts that
  a sensitive name submitted as a key never appears anywhere in the
  redactor's output.

Verification: bun run typecheck clean. test/oauth.test.ts 55/55,
test/mcp-dispatch-summarize.test.ts 6/6,
test/trust-boundary-contract.test.ts 4/4 from commit A. The one
unrelated unit failure surfaces on master too — environment-sensitive
test that expects ~/.gbrain/config.json to be absent in the test env.

Out of scope: F11 (auth register-client --redirect-uri flag) and F13
(serve --http argv positive-int validator) per codex C11 — operator
UX gaps, not trust-boundary fixes. Filed as follow-up TODOs.

Thanks to @ElectricSheepIO on X for the security review that surfaced
this hardening pass.

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

* chore: file F11 + F13 as OAuth hardening follow-up TODOs

Codex C11 flagged these as scope creep on the v0.26.7 OAuth hardening
PR (operator UX, not trust-boundary). Capturing them here so the
context survives — eva-brain has both implementations and the lift is
mechanical when we want to do them.

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

* fix(oauth): close adversarial-review findings on F7c + F8

Two bugs surfaced by an adversarial subagent during /ship's pre-landing
review pass that the codex + plan-eng-review didn't catch.

D15 / F7c: `exchangeAuthorizationCode` used `redirectUri ? ...` ternary
to choose the with-redirect vs no-redirect SQL. Empty string fell
through to the no-redirect branch, so a caller submitting
`redirect_uri=""` at /token bypassed the binding entirely. RFC 6749
§4.1.3 spec violation. Switch to `redirectUri !== undefined`. Test:
empty-string redirect_uri must reject when /authorize stored a real URI.

D16 / F8: `summarizeMcpParams` published exact byte length via
`approx_bytes = JSON.stringify(params).length`. Submitting put_page with
a known prefix and observing the resulting log entry across repeated
probes lets an attacker binary-search the size of secret suffix content.
Bucket to 1KB resolution. The redacted summary keeps a coarse
"roughly how big" signal for operators while making size-based
side-channel attacks useless.

Test count: 65 → 67 across the three new test files.
Typecheck clean.

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

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

OAuth 2.1 hardening + HTTP MCP shell-job RCE fix.

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

* docs: update project documentation for v0.26.9

Annotate CLAUDE.md key-files entries with v0.26.9 OAuth/MCP hardening pass:
- src/core/operations.ts: D12 (OperationContext.remote required) + F7b
  (4-site fail-closed flip), HTTP MCP shell-job RCE close
- src/core/utils.ts: D14 isUndefinedColumnError extracted helper
- src/mcp/dispatch.ts: F8 summarizeMcpParams privacy redactor with
  declared-keys allow-list + 1KB byte bucketing
- src/commands/serve-http.ts: F7+F8+F9+F10+F12+F14+F15 hardening
- src/core/oauth-provider.ts: F1+F2+F3+F4+F5+F6+F7c+F12 RFC 6749/7009
  hardening pass

Add new test-file entries for test/mcp-dispatch-summarize.test.ts
(7 cases) and test/trust-boundary-contract.test.ts (4 cases). Extend
test/oauth.test.ts (+14 cases) and test/e2e/serve-http-oauth.test.ts
(+2 RCE-close regressions) entries with v0.26.9 case counts.

README.md: added --log-full-params to gbrain serve --http surface.

SECURITY.md: documented mcp_request_log.params redaction default
({redacted, kind, declared_keys, unknown_key_count, approx_bytes}) +
--log-full-params opt-in.

docs/mcp/DEPLOY.md: operator-facing note on SSE feed + audit log
redaction default and when to flip --log-full-params on.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:11:15 -07:00

8.9 KiB

Deploy GBrain Remote MCP Server

v0.26.0+: gbrain serve --http ships full OAuth 2.1 (client credentials, auth code + PKCE, refresh rotation, optional DCR), an embedded React admin dashboard at /admin, scoped operations, and a live SSE activity feed. Pre-v0.26 legacy bearer tokens still work — verifyAccessToken falls back to the access_tokens table and grandfathers tokens to read+write+admin. Postgres-only for the legacy fallback (the access_tokens table is Postgres-only); OAuth tables work on both PGLite and Postgres. See SECURITY.md for env vars and tunable defaults.

Access your brain from any device, any AI client. GBrain ships two transports: gbrain serve (stdio) for local agents, and gbrain serve --http (v0.26.0+) for remote clients over OAuth 2.1.

Three Paths

Local stdio (zero setup)

gbrain serve

Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio. No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.

gbrain serve --http --port 3131
ngrok http 3131 --url your-brain.ngrok.app
gbrain serve --http --port 3131 --public-url https://your-brain.ngrok.app

Built-in HTTP transport with OAuth 2.1, scoped operations, an admin dashboard at /admin, and a live SSE activity feed. Zero external dependencies. This is the only path that works with ChatGPT (OAuth 2.1 + PKCE is required by the ChatGPT MCP connector). Pass --public-url whenever the server is reachable at anything other than http://localhost:<port> so the OAuth issuer in discovery metadata matches what clients hit (RFC 8414 §3.3).

Supported clients:

  • ChatGPT — requires OAuth 2.1 + PKCE. Works natively with --http.
  • Claude Desktop / Cowork — OAuth 2.1 or legacy bearer tokens.
  • Perplexity — OAuth 2.1 client credentials grant.
  • Claude Code, Cursor, Windsurf — can use OAuth or legacy bearer.

See the OAuth 2.1 setup section below.

Remote with legacy bearer tokens (pre-v0.26 deployments) — Postgres only

Your AI client (Claude Desktop, Perplexity, etc.)
  → ngrok tunnel (https://YOUR-DOMAIN.ngrok.app)
  → gbrain serve --http  (built-in transport with bearer auth)
  → Postgres (pooler connection or self-hosted)

This requires:

  1. A Postgres-backed brain (the access_tokens table only exists on Postgres; running gbrain serve --http against a PGLite install fails fast at startup)
  2. A machine running gbrain serve --http
  3. A public tunnel (ngrok, Tailscale, or cloud host)
  4. A bearer token created via gbrain auth create <name>

Pre-v1.0 tokens are grandfathered as read+write+admin scopes when you upgrade to the HTTP server, so no migration is required.

OAuth 2.1 Setup (v0.26.0+)

1. Start the HTTP server

gbrain serve --http --port 3131

On first start, the server prints an admin bootstrap token to stderr:

Admin bootstrap token: 3a1f9c...
Open http://localhost:3131/admin and paste it to log in.

Save this token. Open http://localhost:3131/admin and paste it to access the dashboard. The dashboard shows live activity, registered clients, request logs, and per-client config export.

v0.26.9+: mcp_request_log.params and the live SSE activity feed default to a redacted summary {redacted, kind, declared_keys, unknown_key_count, approx_bytes}. Declared param keys are kept (intersected against the operation's spec); unknown keys are counted but never named, and byte sizes round up to 1KB so size-probe attacks can't binary-search secret content. Operators on a personal laptop who want raw payloads back can pass gbrain serve --http --log-full-params (loud stderr warning fires at startup). Multi-tenant deployments should leave it on the redacted default.

2. Register OAuth clients

Register clients from the /admin dashboard:

  1. Click Register client.
  2. Enter a name (e.g. perplexity, chatgpt).
  3. Pick scopes: read, write, admin (checkboxes).
  4. Pick grant type: client_credentials for machine-to-machine (Perplexity, Claude Desktop bearer mode) or authorization_code for browser-based clients with PKCE (ChatGPT).
  5. For authorization_code clients, paste the redirect URI.
  6. Hit Register. The credential-reveal modal shows the client_id (and client_secret for confidential clients) once. Copy or Download JSON immediately — secrets are hashed on storage and never shown again.

Or from the CLI — faster for scripting:

gbrain auth register-client perplexity \
  --grant-types client_credentials \
  --scopes "read write"

Host-repo wrappers can register programmatically:

await oauthProvider.registerClientManual(
  'perplexity',
  ['client_credentials'],
  'read write',
  [],  // redirect_uris, empty for CC
);

For self-service client registration (Dynamic Client Registration, RFC 7591), start the server with --enable-dcr. DCR is off by default.

3. Expose the server

brew install ngrok
ngrok config add-authtoken YOUR_TOKEN
ngrok http 3131 --url your-brain.ngrok.app

Your OAuth issuer URL becomes https://your-brain.ngrok.app. The MCP SDK's router exposes the spec-compliant discovery endpoint at /.well-known/oauth-authorization-server.

4. Scopes and localOnly

Every operation is tagged read | write | admin. Four operations are localOnly and rejected over HTTP regardless of scope: sync_brain, file_upload, file_list, file_url. Remote agents cannot reach local filesystem surface area.

Scope What it allows
read search, query, get_page, list_pages, graph traversal
write put_page, delete_page, add_link, add_timeline_entry
admin Client management, token revocation, sweep, local-only ops

Legacy Bearer Token Setup

Keep using pre-v0.26 bearer tokens if you aren't ready to migrate. They grandfather to read+write+admin scopes on the HTTP server.

1. Set up the tunnel

See the ngrok-tunnel recipe for full setup. Quick version:

brew install ngrok
ngrok config add-authtoken YOUR_TOKEN
ngrok http 8787 --url your-brain.ngrok.app  # Hobby tier for fixed domain

2. Create access tokens

# Create a token for each client
gbrain auth create "claude-desktop"

# List all tokens
gbrain auth list

# Revoke a token
gbrain auth revoke "claude-desktop"

Tokens are per-client. Create one for each device/app. Revoke individually if compromised. Tokens are stored SHA-256 hashed in your database.

3. Connect your AI client

4. Verify

gbrain auth test \
  https://YOUR-DOMAIN.ngrok.app/mcp \
  --token YOUR_TOKEN

Operations

All 30 GBrain operations are available remotely, including sync_brain and file_upload (no timeout limits with self-hosted server).

Security note on file_upload: remote MCP callers are confined to the working directory where gbrain serve was launched. Symlinks, .. traversal, and absolute paths outside cwd are rejected. Page slugs and filenames are allowlist-validated (alphanumeric + hyphens; no control chars, RTL overrides, or backslashes). Local CLI callers (gbrain file upload ...) keep unrestricted filesystem access since the user owns the machine.

Deployment Options

See ALTERNATIVES.md for a comparison of ngrok, Tailscale Funnel, and cloud hosts (Fly.io, Railway).

Troubleshooting

"missing_auth" error Include the Authorization header: Authorization: Bearer YOUR_TOKEN

"invalid_token" error Run gbrain auth list to see active tokens.

"service_unavailable" error Database connection failed. Check your Supabase dashboard for outages.

Claude Desktop doesn't connect Remote servers must be added via Settings > Integrations, NOT claude_desktop_config.json. See CLAUDE_DESKTOP.md.

Expected Latencies

Operation Typical Latency Notes
get_page < 100ms Single DB query
list_pages < 200ms DB query with filters
search (keyword) 100-300ms Full-text search
query (hybrid) 1-3s Embedding + vector + keyword + RRF
put_page 100-500ms Write + trigger search_vector update
get_stats < 100ms Aggregate query

Note: gbrain serve --http shipped in v0.26.0 with OAuth 2.1 + admin dashboard baked into the binary. The custom HTTP wrapper pattern (see voice recipe) is still supported for teams that need bespoke middleware, but for most remote deployments the built-in server is the recommended path.