security: enforce scopes on remote MCP + log real op names#45
Closed
garagon wants to merge 1 commit into
Closed
Conversation
The Supabase Edge Function at supabase/functions/gbrain-mcp/index.ts
exposed every non-excluded operation to any valid bearer token,
including destructive ones (put_page, delete_page, put_raw_data,
revert_version, log_ingest). The access_tokens.scopes column existed
in schema.sql but was never read, so a token created "read-only"
could still call delete_page.
The audit trail compounded the problem: logRequest was called with
the literal string 'mcp_request' in both success and error branches,
so mcp_request_log rows recorded only that a call happened, never
which tool ran. Forensic analysis after an incident was impossible.
This PR:
- Adds a DESTRUCTIVE_OPS set and rejects calls to those ops when the
authenticated token lacks the 'write' scope. Rejection happens at
the CallToolRequestSchema handler boundary and returns a structured
'forbidden' payload with the token name, the requested op, and a
docs pointer.
- Hides destructive ops from tools/list for read-only tokens so the
MCP client surface matches what the token can actually call.
- Threads `{ name, scopes }` from authenticateToken into createMcpServer
instead of closing over a module-scoped variable.
- Reads access_tokens.scopes (handling the legacy NULL case) so the
column stops being dead code.
- Calls logRequest with the actual operation name in the success,
error, forbidden, and unknown_tool branches. Removes the two
hardcoded 'mcp_request' log sites from the /mcp route — the
per-op entries inside the handler are authoritative.
Backwards compatibility: tokens created before the scopes column was
populated (rawScopes === null) are treated as empty-scope, i.e.
read-only. Existing read-only usage is unaffected. To regain
destructive access, reissue the token with --scopes=write.
Refs: docs/mcp/DEPLOY.md#token-scopes
This was referenced Apr 11, 2026
8 tasks
Owner
|
Closing this PR — the Edge Function at |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The Supabase Edge Function at
supabase/functions/gbrain-mcp/index.tsexposes every non-excluded operation to any holder of a valid bearer token, including destructive ones (put_page,delete_page,put_raw_data,revert_version,log_ingest). Theaccess_tokens.scopescolumn exists inschema.sqlbut is never read at runtime, so a token created as read-only can still calldelete_page. On top of that,logRequest(...)is called with the literal string'mcp_request'in both branches of the/mcphandler, somcp_request_logonly records that a call happened, not which tool ran — post-incident forensics is impossible.This PR fixes both issues at the handler boundary.
Changes
supabase/functions/gbrain-mcp/index.tsDESTRUCTIVE_OPSset coveringput_page,delete_page,put_raw_data,revert_version,log_ingest.createMcpServernow takes{ name, scopes }from the authenticated token. Read-only tokens (no'write'scope) get a filteredtools/listthat hides destructive ops. Calls to destructive ops from a read-only token are rejected in theCallToolRequestSchemahandler with a structuredforbiddenresponse pointing atdocs/mcp/DEPLOY.md#token-scopes.authenticateTokennowSELECTs thescopescolumn and returns it. NULL (legacy tokens pre-scopes) is coerced to[], so legacy tokens keep working as read-only — no existing integrations break.logRequestis now called with the real operation name in the success / error / forbidden / unknown_tool branches. The two hardcodedlogRequest(..., 'mcp_request', ...)sites around the/mcproute are removed — the per-op entries inside the handler are authoritative.Why not just exclude destructive ops from the remote transport
Moving them into
REMOTE_EXCLUDEDwould block every remote caller, including the owner's own write integrations (sync pipelines, ingest workers). The scopes column was already part of the schema and CLI for this exact reason, it just wasn't wired into the edge path. This PR closes the wiring gap instead of shrinking the API.Validation
A deterministic PoC in
report/evidence/poc-f001-remote-destructive-ops.ts(not part of this diff, from the upstream audit) runs 4 bug checks + 1 context check againstindex.ts,schema.sql, andoperations.ts:Before the fix (file stashed, same PoC):
After the fix (this PR):
Backwards compatibility
rawScopes === null) are treated as empty-scope, i.e. read-only. To regain destructive access, reissue the token with--scopes=writeviabun run src/commands/auth.ts create <name> --scopes=write./mcptransport no longer emits the noisy'mcp_request'log rows, which means pre-existing log queries that counted those rows will need to be updated to count per-op entries instead. There's no data loss — the new rows carry strictly more information.Test plan
bun testunit tests passbun run test:e2eE2E tests pass against real Postgres + pgvectorsupabase functions deploy gbrain-mcp --no-verify-jwt), issue two tokens — one with--scopes=write, one without. Verify:tools/listomits destructive opsdelete_pagereturns theforbiddenpayload and inserts aforbiddenrow intomcp_request_logtools/listshows all ops,delete_pagesucceeds,mcp_request_logrecordsoperation = 'delete_page'Not in this PR
CASCADEchain onpagesis intentional — this PR doesn't touch it. It's only referenced to show why remote write access matters.sync_brainandfile_uploadentries inREMOTE_EXCLUDEDare untouched; they're excluded for timeout reasons unrelated to scope enforcement.