Skip to content

feat(mcp): add a read-only D1 query tool and Code Mode endpoint - #285

Open
demosjarco wants to merge 2 commits into
cloudflare:mainfrom
demosjarco:main
Open

demosjarco wants to merge 2 commits into
cloudflare:mainfrom
demosjarco:main

Conversation

@demosjarco

Copy link
Copy Markdown

Why

POST /accounts/{account_id}/d1/database/{database_id}/query accepts a token with either D1 Read or D1 Write, so Cloudflare's permissions don't separate reads from writes on it. Today every D1 query an agent runs through this server goes through a tool that can also drop tables or any other destructive SQL statement . This adds a read-only path that refuses SQL that could write before it reaches D1 (actual D1 query command is identical), so an agent or MCP client can use a separate tool for reads and keep the existing one for everything else.

What

Non-Code-Mode (?codemode=false)

  • New d1_read_query tool (src/tools/d1-read-query.ts), listed next to the generated endpoint tools with readOnlyHint: true, destructiveHint: false, idempotentHint: true, so clients can approve it without prompting.
  • Its inputs are database_id, sql, optional params and optional account_id, and it rejects unknown keys. Refused SQL never reaches the API.
  • post_accounts_d1_database_query is unchanged and still runs any SQL.

Code Mode

  • A virtual POST /accounts/{account_id}/d1/database/{database_id}/read_query (src/tools/d1-read-route.ts). The scheduled handler (and seed-r2.ts) adds it to spec.json as a copy of the real query endpoint, after mcp-tools.json is built, so search finds it but no endpoint tool targets a path the API doesn't have.
  • GlobalOutbound refuses SQL that could write and sends everything else, body unchanged, to the real .../query. It checks every query in a { batch } body and refuses bodies with keys it doesn't check (for example sql and batch together) and methods other than POST. execute's description points to it.
  • Limitation: MCP annotations are per tool, so in Code Mode this is a guarded path the agent chooses, not an approval boundary. execute stays destructive and can still call .../query.

The check (src/tools/d1-sql.ts)

  • In SQLite the statement head decides whether a statement can write: subqueries, CTE bodies and compound members can only be SELECTs. So this is a small tokenizer, not a parser.
  • The tokenizer follows SQLite's tokenize.c rules for --//* */ comments, '…' strings with '' escapes and no backslash escapes, and "…", `…` and […] identifiers, so text it treats as a literal or a comment is what SQLite treats that way.
  • Every ;-separated statement must be SELECT, VALUES, WITH … SELECT/VALUES, EXPLAIN [QUERY PLAN] of one of those, or one of the reporting PRAGMAs D1 supports: table_list, table_info, table_xinfo, index_list, index_info, index_xinfo, quick_check, foreign_key_check, foreign_key_list.
  • For WITH, the main statement keyword is the first SELECT/VALUES/INSERT/UPDATE/DELETE/REPLACE outside parentheses, which catches WITH x AS (…) DELETE ….
  • Anything unclassifiable, such as an unterminated string, is refused.

Dependencies

None added. Two parsers were considered:

  • sql-parser-cst is GPL-2.0-or-later, which doesn't fit this Apache-2.0 repo.
  • node-sql-parser's SQLite grammar rejects valid reads such as EXPLAIN QUERY PLAN, PRAGMA table_info(t), VALUES, WINDOW, FILTER and NULLS LAST.

Notes for review

  • Spec pipeline change: AGENTS.md asks to check before changing it. The virtual path appears in search after the next six-hourly sync or npm run seed:*.
  • Unverified assumption: this assumes D1 splits multi-statement SQL with SQLite's own tokenizer. If D1 pre-splits differently, for example treating backslash as an escape, the check could disagree with D1 on statement boundaries.

Tests

npm run check passes: 544 tests. They cover:

  • tokenizer and classifier cases
  • d1_read_query through the real worker, including a refusal with no outbound request (MSW fails on unhandled requests)
  • read_query through a real execute isolate and GlobalOutbound
  • the scheduled handler writing the virtual path to spec.json but not to mcp-tools.json

Existing tests that pin the non-Code-Mode tool list were updated.

🤖 Generated with Claude Code

With ?codemode=false, list d1_read_query next to the generated endpoint
tools, annotated readOnlyHint: true so clients can approve it without
asking. It runs SQL through POST /accounts/{account_id}/d1/database/
{database_id}/query only when every statement is SELECT, VALUES,
WITH ... SELECT, EXPLAIN of one of those, or an allowlisted reporting
PRAGMA. Other SQL is refused before any API call.

The check is a small tokenizer that follows SQLite's rules for comments,
strings and quoted identifiers. In SQLite the statement head decides
whether a statement can write, because subqueries and CTE bodies can only
be SELECTs. The WITH case finds the main statement keyword outside
parentheses. sql-parser-cst is GPL-2.0-or-later, and node-sql-parser's
SQLite grammar rejects EXPLAIN QUERY PLAN, PRAGMA, VALUES, WINDOW, FILTER
and NULLS LAST, so neither is used.

post_accounts_d1_database_query is unchanged and still runs any SQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kuoB4UxMwTp2hrqUCyv63
Code Mode reaches D1 through execute, so add the read-only check there as
an endpoint: POST /accounts/{account_id}/d1/database/{database_id}/read_query.
The scheduled handler adds it to spec.json, copied from the real query
endpoint, after building mcp-tools.json, so search finds it but no
endpoint tool targets a path the API doesn't have. GlobalOutbound refuses
SQL that could write, checking every query in a batch and refusing bodies
with keys it doesn't read, and sends the rest unchanged to .../query.
execute's description points to it.

The real query endpoint accepts a D1 Read token, so this check is what
separates reads from writes. execute stays one destructive tool and can
still call .../query.

Limit the reporting PRAGMAs to the ones D1 supports: table_list,
table_info, table_xinfo, index_list, index_info, index_xinfo, quick_check,
foreign_key_check and foreign_key_list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kuoB4UxMwTp2hrqUCyv63
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants