From 4f8dffff4b763dd112f776514544c736ffe4d192 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 21:25:20 +0000 Subject: [PATCH 1/2] feat(non-codemode): add d1_read_query, a read-only D1 SQL tool 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 Claude-Session: https://claude.ai/code/session_019kuoB4UxMwTp2hrqUCyv63 --- AGENTS.md | 4 +- README.md | 2 + src/tools/d1-read-query.ts | 106 ++++++++++++ src/tools/d1-sql.ts | 270 ++++++++++++++++++++++++++++++ src/tools/non-codemode.ts | 8 +- tests/d1-sql.test.ts | 56 +++++++ tests/mcp-modern.test.ts | 1 + tests/non-codemode-worker.test.ts | 70 ++++++++ tests/non-codemode.test.ts | 2 +- 9 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 src/tools/d1-read-query.ts create mode 100644 src/tools/d1-sql.ts create mode 100644 tests/d1-sql.test.ts diff --git a/AGENTS.md b/AGENTS.md index f5756e2..b457599 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,9 @@ The core innovation: instead of 2,500 MCP tools (~244K tokens), two tools handle 1. **`search` tool** — Agents write JavaScript to query the pre-resolved OpenAPI spec (all `$ref`s inlined). Runs in an isolated worker with no network access. 2. **`execute` tool** — Agents write JavaScript using `cloudflare.request()` to call discovered endpoints. Runs in an isolated worker with outbound restricted to Cloudflare API URLs only. -`docs` (documentation search) and `whoami` are registered in both tool modes. `whoami` (`src/tools/whoami.ts`) returns the credential's identity as `user:` or `account:` and carries `_meta['openai/profile']` so ChatGPT can tell connected accounts apart. That ID format is a permanent contract: clients store it, so never change it. Its definition is static like every other tool's; only `tools/call` reads the credential. +`docs` (documentation search) and `whoami` are registered in both tool modes. + +With `?codemode=false`, `d1_read_query` (`src/tools/d1-read-query.ts`) is listed next to the generated endpoint tools with `readOnlyHint: true`. `src/tools/d1-sql.ts` tokenizes the SQL with SQLite's own rules for comments, strings and quoted identifiers, and accepts it only when every statement is `SELECT`, `VALUES`, `WITH … SELECT`, `EXPLAIN` of one of those, or an allowlisted reporting `PRAGMA`. In SQLite the statement head decides this, because subqueries and CTE bodies can only be `SELECT`s. Refused SQL never reaches the API. The generated `post_accounts_d1_database_query` tool still runs any SQL. Don't swap the tokenizer for `sql-parser-cst`: it is GPL-2.0-or-later. `whoami` (`src/tools/whoami.ts`) returns the credential's identity as `user:` or `account:` and carries `_meta['openai/profile']` so ChatGPT can tell connected accounts apart. That ID format is a permanent contract: clients store it, so never change it. Its definition is static like every other tool's; only `tools/call` reads the credential. ### MCP HTTP serving diff --git a/README.md b/README.md index dbdc2b1..bc46e51 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Create a [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens) If your MCP client already uses code mode, or you're composing this server with another server that uses code mode, you can disable it with the `?codemode=false` query parameter. This registers an individual tool for each of the ~2,500 Cloudflare API endpoints instead of the code mode API tools. The `docs` and `whoami` tools remain available in both modes. +This mode also adds `d1_read_query`, which runs SQL against a D1 database only when every statement is a read: `SELECT`, `VALUES`, `WITH … SELECT`, `EXPLAIN` of one of those, or a schema-reading `PRAGMA` such as `table_info`. Anything else is refused before it reaches D1. It is annotated `readOnlyHint: true`, so a client can approve it without asking. `post_accounts_d1_database_query` still runs any SQL. + ``` https://mcp.cloudflare.com/mcp?codemode=false ``` diff --git a/src/tools/d1-read-query.ts b/src/tools/d1-read-query.ts new file mode 100644 index 0000000..f5b1e37 --- /dev/null +++ b/src/tools/d1-read-query.ts @@ -0,0 +1,106 @@ +import { z } from 'zod' +import { env } from 'cloudflare:workers' +import type { CallToolResult, Tool } from '@modelcontextprotocol/server' +import type { FormatToolResult } from '../truncate' +import { fetchWithRetry } from '../utils/fetch-retry' +import { + NON_CODEMODE_ACCOUNT_DISCOVERY_GUIDANCE, + autoResolvedAccountId, + missingAccountMessage, + unknownAccountHint +} from '../auth/account-access' +import { ACCOUNT_ID_PARAM_DESCRIPTION } from '../openapi' +import type { AuthProps } from '../auth/types' +import { classifyD1Sql } from './d1-sql' + +const NAME = 'd1_read_query' +const TITLE = 'Read-Only D1 Query' + +/** The generated endpoint tool that runs any SQL, named for writes. */ +const WRITE_TOOL_NAME = 'post_accounts_d1_database_query' + +const DESCRIPTION = + 'Run read-only SQL against a D1 database (POST /accounts/{account_id}/d1/database/{database_id}/query). ' + + 'Every statement must be SELECT, VALUES, WITH … SELECT, EXPLAIN [QUERY PLAN] of one of those, or a ' + + 'schema-reading PRAGMA such as table_list or table_info; anything else is refused before it reaches D1. ' + + `Separate several statements with semicolons. Use ${WRITE_TOOL_NAME} to change data or schema.` + +export const D1ReadQueryInputSchema = z.strictObject({ + account_id: z.string().optional().describe(ACCOUNT_ID_PARAM_DESCRIPTION), + database_id: z.string().describe('D1 database ID (UUID).'), + sql: z.string().describe('The SQL to run. Use ? placeholders for values.'), + params: z.array(z.string()).optional().describe('Values bound to the ? placeholders, in order.') +}) + +type D1ReadQueryInput = z.infer + +/** + * Wire definition served by the non-Code-Mode `tools/list`. The hints let a + * client approve this tool on its own, which is why the SQL is checked + * before the call rather than trusted. + */ +export const D1_READ_QUERY_TOOL: Tool = { + name: NAME, + title: TITLE, + description: DESCRIPTION, + inputSchema: z.toJSONSchema(D1ReadQueryInputSchema) as Tool['inputSchema'], + annotations: { + title: TITLE, + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + } +} + +/** + * Run `d1_read_query`: refuse SQL that could write, then send the rest to D1's + * query endpoint. + * + * @param input - The validated tool arguments. + * @param props - The request's validated credential. + * @param formatResult - Turns the API response body into the tool's text output. + */ +export async function runD1ReadQuery( + input: D1ReadQueryInput, + props: AuthProps, + formatResult: FormatToolResult +): Promise { + const verdict = classifyD1Sql(input.sql) + if (!verdict.readOnly) { + return toolError(`Refused: ${verdict.reason} Use ${WRITE_TOOL_NAME} for other SQL.`) + } + + const accountId = input.account_id || autoResolvedAccountId(props) + if (!accountId) { + return toolError(missingAccountMessage(props, NON_CODEMODE_ACCOUNT_DISCOVERY_GUIDANCE)) + } + + const path = `/accounts/${encodeURIComponent(accountId)}/d1/database/${encodeURIComponent(input.database_id)}/query` + const response = await fetchWithRetry( + env.CLOUDFLARE_API_BASE + path, + { + method: 'POST', + headers: { + Authorization: `Bearer ${props.accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ sql: input.sql, ...(input.params && { params: input.params }) }) + }, + { caller: 'd1_read_query' } + ) + const contentType = response.headers.get('content-type') || '' + const result = contentType.includes('application/json') + ? await response.json() + : await response.text() + + const hint = !response.ok && input.account_id ? unknownAccountHint(props, input.account_id) : '' + return { + content: [{ type: 'text', text: formatResult(result) + (hint ? `\n\n${hint}` : '') }], + isError: !response.ok + } +} + +function toolError(message: string): CallToolResult { + return { content: [{ type: 'text', text: message }], isError: true } +} diff --git a/src/tools/d1-sql.ts b/src/tools/d1-sql.ts new file mode 100644 index 0000000..671d350 --- /dev/null +++ b/src/tools/d1-sql.ts @@ -0,0 +1,270 @@ +/** + * Decides whether SQL sent to D1 can only read. + * + * In SQLite only the statement's head decides this: subqueries, CTE bodies and + * compound members can only be SELECTs, so a SELECT, VALUES or WITH … SELECT + * statement can't write. The checks therefore need a tokenizer, not a parser. + * The tokenizer follows SQLite's own rules for comments, strings and quoted + * identifiers (tokenize.c), so text it treats as a literal or a comment is + * what SQLite treats that way. Anything it can't classify is rejected. + */ + +/** The verdict for a SQL string. `reason` says which statement was rejected and why. */ +export type SqlVerdict = { readOnly: true } | { readOnly: false; reason: string } + +type Token = + | { kind: 'word'; upper: string } + | { kind: 'open' } + | { kind: 'close' } + | { kind: 'semicolon' } + | { kind: 'symbol'; text: string } + | { kind: 'other' } + +/** + * PRAGMAs that only report. Each reads the schema or checks integrity and + * changes nothing, with or without its argument. The PRAGMAs that set a value + * also accept `PRAGMA name(value)`, so they are left out rather than told + * apart by syntax. + */ +const READ_ONLY_PRAGMAS = new Set([ + 'COLLATION_LIST', + 'COMPILE_OPTIONS', + 'DATABASE_LIST', + 'FOREIGN_KEY_CHECK', + 'FOREIGN_KEY_LIST', + 'FUNCTION_LIST', + 'INDEX_INFO', + 'INDEX_LIST', + 'INDEX_XINFO', + 'INTEGRITY_CHECK', + 'MODULE_LIST', + 'PRAGMA_LIST', + 'QUICK_CHECK', + 'TABLE_INFO', + 'TABLE_LIST', + 'TABLE_XINFO' +]) + +/** Keywords that can start the main statement after a WITH clause. */ +const WITH_MAIN_KEYWORDS = new Set(['SELECT', 'VALUES', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE']) + +const ALLOWED = + 'Only SELECT, VALUES, WITH … SELECT, EXPLAIN and read-only PRAGMA statements are allowed' + +/** SQLite's whitespace is ASCII only (tokenize.c `CC_SPACE`). */ +const SPACE = /[ \t\n\f\r]/ +/** SQLite treats every byte >= 0x80 as an identifier character. */ +const WORD_START = /[A-Za-z_\u0080-￿]/ +const WORD_PART = /[A-Za-z0-9_$\u0080-￿]/ + +/** + * Splits SQL into the tokens the classifier needs. Throws on input SQLite + * would also reject as unterminated, so nothing is left ambiguous. + */ +class SqliteTokenizer { + private position = 0 + + constructor(private readonly sql: string) {} + + tokenize(): Token[] { + const tokens: Token[] = [] + for (let token = this.next(); token !== undefined; token = this.next()) { + tokens.push(token) + } + return tokens + } + + private next(): Token | undefined { + this.skipSpaceAndComments() + if (this.position >= this.sql.length) return undefined + + const char = this.sql[this.position] + switch (char) { + case "'": + this.skipQuoted("'", 'string literal') + return { kind: 'other' } + case '"': + this.skipQuoted('"', 'quoted identifier') + return { kind: 'other' } + case '`': + this.skipQuoted('`', 'quoted identifier') + return { kind: 'other' } + case '[': + this.skipBracketIdentifier() + return { kind: 'other' } + case '(': + this.position++ + return { kind: 'open' } + case ')': + this.position++ + return { kind: 'close' } + case ';': + this.position++ + return { kind: 'semicolon' } + } + + if (WORD_START.test(char)) return { kind: 'word', upper: this.readWord().toUpperCase() } + if (/[0-9]/.test(char) || char === '?' || char === ':' || char === '@' || char === '$') { + // Numbers and bind parameters (?1, :name, @name, $name). + this.position++ + this.readWord() + return { kind: 'other' } + } + + this.position++ + return { kind: 'symbol', text: char } + } + + private skipSpaceAndComments(): void { + while (this.position < this.sql.length) { + const char = this.sql[this.position] + if (SPACE.test(char)) { + this.position++ + } else if (this.sql.startsWith('--', this.position)) { + const end = this.sql.indexOf('\n', this.position) + this.position = end === -1 ? this.sql.length : end + 1 + } else if (this.sql.startsWith('/*', this.position)) { + // SQLite lets a block comment run to the end of the input. + const end = this.sql.indexOf('*/', this.position + 2) + this.position = end === -1 ? this.sql.length : end + 2 + } else { + return + } + } + } + + /** Skips a quoted token in which a doubled quote stands for one quote. */ + private skipQuoted(quote: string, what: string): void { + let index = this.position + 1 + for (;;) { + const end = this.sql.indexOf(quote, index) + if (end === -1) throw new Error(`unterminated ${what}`) + if (this.sql[end + 1] !== quote) { + this.position = end + 1 + return + } + index = end + 2 + } + } + + /** Skips a `[identifier]`, which has no escape. */ + private skipBracketIdentifier(): void { + const end = this.sql.indexOf(']', this.position + 1) + if (end === -1) throw new Error('unterminated quoted identifier') + this.position = end + 1 + } + + private readWord(): string { + const start = this.position + while (this.position < this.sql.length && WORD_PART.test(this.sql[this.position])) { + this.position++ + } + return this.sql.slice(start, this.position) + } +} + +/** + * Classifies a SQL string, which may hold several `;`-separated statements, as + * read-only or not. Every statement has to be read-only. + * + * @param sql - The SQL D1 would run. + * @returns `{ readOnly: true }`, or `{ readOnly: false, reason }` naming the problem. + */ +export function classifyD1Sql(sql: string): SqlVerdict { + let tokens: Token[] + try { + tokens = new SqliteTokenizer(sql).tokenize() + } catch (error) { + return { readOnly: false, reason: `Could not read the SQL: ${(error as Error).message}.` } + } + + const statements: Token[][] = [[]] + for (const token of tokens) { + if (token.kind === 'semicolon') statements.push([]) + else statements[statements.length - 1].push(token) + } + + const nonEmpty = statements.filter((statement) => statement.length > 0) + if (nonEmpty.length === 0) return { readOnly: false, reason: 'The SQL holds no statement.' } + + for (const [index, statement] of nonEmpty.entries()) { + const problem = statementProblem(statement) + if (problem) { + const which = nonEmpty.length > 1 ? `Statement ${index + 1}: ` : '' + return { readOnly: false, reason: `${which}${problem}` } + } + } + return { readOnly: true } +} + +/** Why a single statement isn't read-only, or `undefined` when it is. */ +function statementProblem(statement: Token[]): string | undefined { + const [head] = statement + const keyword = head.kind === 'word' ? head.upper : undefined + + switch (keyword) { + case 'SELECT': + case 'VALUES': + return undefined + case 'WITH': + return withProblem(statement) + case 'EXPLAIN': + return explainProblem(statement) + case 'PRAGMA': + return pragmaProblem(statement) + default: + return `${keyword ?? 'This'} statement is not read-only. ${ALLOWED}.` + } +} + +/** + * A WITH clause is followed by its main statement, which SQLite lets be a + * write. CTE names, column lists and bodies sit in or before parentheses, so + * the first SELECT/VALUES/INSERT/UPDATE/DELETE/REPLACE outside parentheses is + * the main statement's keyword. + */ +function withProblem(statement: Token[]): string | undefined { + let depth = 0 + for (const token of statement) { + if (token.kind === 'open') depth++ + else if (token.kind === 'close') depth-- + else if (depth === 0 && token.kind === 'word' && WITH_MAIN_KEYWORDS.has(token.upper)) { + return token.upper === 'SELECT' || token.upper === 'VALUES' + ? undefined + : `WITH … ${token.upper} statement is not read-only. ${ALLOWED}.` + } + } + return `Could not find the statement after WITH. ${ALLOWED}.` +} + +/** + * EXPLAIN only describes a statement and never runs it. The explained + * statement still has to be read-only, so this tool only ever accepts reads. + */ +function explainProblem(statement: Token[]): string | undefined { + let rest = statement.slice(1) + const [first, second] = rest + if (first?.kind === 'word' && first.upper === 'QUERY') { + if (second?.kind !== 'word' || second.upper !== 'PLAN') return 'Expected EXPLAIN QUERY PLAN.' + rest = rest.slice(2) + } + if (rest.length === 0) return 'EXPLAIN needs a statement to explain.' + const problem = statementProblem(rest) + return problem ? `EXPLAIN: ${problem}` : undefined +} + +/** Accepts `PRAGMA [schema.]name [(argument)]` for the PRAGMAs that only report. */ +function pragmaProblem(statement: Token[]): string | undefined { + let rest = statement.slice(1) + if (rest[1]?.kind === 'symbol' && rest[1].text === '.') rest = rest.slice(2) + + const [name] = rest + if (name?.kind !== 'word' || !READ_ONLY_PRAGMAS.has(name.upper)) { + const allowed = [...READ_ONLY_PRAGMAS].map((pragma) => pragma.toLowerCase()).join(', ') + return `Only these read-only PRAGMAs are allowed: ${allowed}.` + } + if (rest.some((token) => token.kind === 'symbol' && token.text === '=')) { + return `PRAGMA ${name.upper.toLowerCase()} can't be assigned in a read-only query.` + } + return undefined +} diff --git a/src/tools/non-codemode.ts b/src/tools/non-codemode.ts index 303f94d..c480e64 100644 --- a/src/tools/non-codemode.ts +++ b/src/tools/non-codemode.ts @@ -13,6 +13,7 @@ import { import { recordToolCall } from '../metrics' import { DOCS_TOOL, runDocsTool } from './docs-search' import { WHOAMI_TOOL, WhoamiInputSchema, runWhoamiTool } from './whoami' +import { D1_READ_QUERY_TOOL, D1ReadQueryInputSchema, runD1ReadQuery } from './d1-read-query' import { zodInputSchemaFromJson, type McpTool } from '../openapi' import type { AuthProps } from '../auth/types' @@ -33,7 +34,7 @@ export async function registerNonCodemodeTools( const tools = await getMcpTools() server.server.setRequestHandler('tools/list', () => ({ - tools: [DOCS_TOOL, WHOAMI_TOOL, ...tools.list] + tools: [DOCS_TOOL, WHOAMI_TOOL, D1_READ_QUERY_TOOL, ...tools.list] })) server.server.setRequestHandler('tools/call', async (request) => { @@ -49,6 +50,11 @@ export async function registerNonCodemodeTools( } else if (name === WHOAMI_TOOL.name) { const parsed = WhoamiInputSchema.safeParse(request.params.arguments ?? {}) result = parsed.success ? runWhoamiTool(props) : validationError(name, parsed.error) + } else if (name === D1_READ_QUERY_TOOL.name) { + const parsed = D1ReadQueryInputSchema.safeParse(request.params.arguments ?? {}) + result = parsed.success + ? await runD1ReadQuery(parsed.data, props, formatResult) + : validationError(name, parsed.error) } else { const tool = tools.byName.get(name) if (!tool) { diff --git a/tests/d1-sql.test.ts b/tests/d1-sql.test.ts new file mode 100644 index 0000000..aeeefaa --- /dev/null +++ b/tests/d1-sql.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { classifyD1Sql } from '../src/tools/d1-sql' + +describe('classifyD1Sql', () => { + it.each([ + 'SELECT * FROM users WHERE id = ?', + 'select replace(name, "a", "b") from users', + 'SELECT 1; SELECT 2;', + 'VALUES (1), (2)', + 'WITH recent AS (SELECT * FROM orders) SELECT count(*) FROM recent', + 'WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM c WHERE n < 5) SELECT n FROM c', + 'WITH x AS MATERIALIZED (SELECT 1), y AS (SELECT 2) VALUES (3)', + 'EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?1', + 'explain select 1', + 'PRAGMA table_list', + 'PRAGMA table_info(users)', + 'pragma main.index_list("users")', + "SELECT * FROM pragma_table_info('users')", + "SELECT ';DELETE FROM users' AS s", + 'SELECT "delete" FROM [update] -- ; DROP TABLE users', + 'SELECT 1 /* ; DROP TABLE users */', + "SELECT 'it''s'; SELECT `a``b` FROM t", + 'SELECT count(*) FILTER (WHERE a > 1) OVER w FROM t WINDOW w AS (ORDER BY a NULLS LAST)' + ])('accepts %s', (sql) => { + expect(classifyD1Sql(sql)).toEqual({ readOnly: true }) + }) + + it.each([ + ['INSERT INTO users VALUES (1)', 'INSERT statement is not read-only'], + ['replace into users values (1)', 'REPLACE statement is not read-only'], + ['SELECT 1; DROP TABLE users', 'Statement 2: DROP statement is not read-only'], + ['SELECT 1;DELETE FROM users', 'Statement 2: DELETE'], + ['WITH x AS (SELECT 1) DELETE FROM users WHERE id IN (SELECT * FROM x)', 'WITH … DELETE'], + ['WITH x AS (SELECT 1) UPDATE users SET a = 1', 'WITH … UPDATE'], + ['WITH x AS (SELECT 1)', 'Could not find the statement after WITH'], + ['EXPLAIN DELETE FROM users', 'EXPLAIN: DELETE statement is not read-only'], + ['EXPLAIN QUERY', 'Expected EXPLAIN QUERY PLAN'], + ['PRAGMA foreign_keys = OFF', 'Only these read-only PRAGMAs'], + ['PRAGMA foreign_keys(0)', 'Only these read-only PRAGMAs'], + ['PRAGMA table_info = 1', "PRAGMA table_info can't be assigned"], + ['PRAGMA optimize', 'Only these read-only PRAGMAs'], + ['ATTACH DATABASE "x" AS y', 'ATTACH statement is not read-only'], + ['CREATE TABLE t (a)', 'CREATE statement is not read-only'], + ['BEGIN; SELECT 1; COMMIT', 'BEGIN statement is not read-only'], + ["SELECT 'a\\'; DELETE FROM users; --'", 'Statement 2: DELETE'], + ["SELECT 'unterminated", 'unterminated string literal'], + ['SELECT [unterminated', 'unterminated quoted identifier'], + ['', 'holds no statement'], + [' ; -- nothing', 'holds no statement'], + ['(SELECT 1)', 'This statement is not read-only'] + ])('rejects %s', (sql, reason) => { + const verdict = classifyD1Sql(sql) + expect(verdict.readOnly).toBe(false) + expect(verdict.readOnly === false && verdict.reason).toContain(reason) + }) +}) diff --git a/tests/mcp-modern.test.ts b/tests/mcp-modern.test.ts index 912b44d..dd1c8c8 100644 --- a/tests/mcp-modern.test.ts +++ b/tests/mcp-modern.test.ts @@ -288,6 +288,7 @@ describe('MCP 2026-07-28 stateless handler', () => { expect(endpoints.result?.tools?.map((tool) => tool.name)).toEqual([ 'docs', 'whoami', + 'd1_read_query', 'get_accounts_workers_scripts' ]) expect(codemodeResponse.headers.get('mcp-session-id')).toBeNull() diff --git a/tests/non-codemode-worker.test.ts b/tests/non-codemode-worker.test.ts index 2279a23..b2ba610 100644 --- a/tests/non-codemode-worker.test.ts +++ b/tests/non-codemode-worker.test.ts @@ -197,3 +197,73 @@ describe('non-codemode: account_id auto-resolution through real MCP validation', expect(result.result?.isError).toBe(true) }) }) + +describe('non-codemode: d1_read_query', () => { + const DATABASE_ID = 'd1d1d1d1-0000-0000-0000-000000000001' + const QUERY_URL = `${API_BASE}/accounts/${ACCOUNT_ID}/d1/database/${DATABASE_ID}/query` + + it('is listed with read-only annotations next to the generated endpoint tools', async () => { + mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] }) + + const tools = (await listNonCodemodeTools(ACCOUNT_TOKEN)) as Array<{ + name: string + annotations?: Record + inputSchema: { required?: string[] } + }> + const tool = tools.find((candidate) => candidate.name === 'd1_read_query') + + expect(tool?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }) + expect(tool?.inputSchema.required).toEqual(['database_id', 'sql']) + }) + + it('sends read-only SQL and its params to the D1 query endpoint', async () => { + mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] }) + let body: unknown + let auth: string | null = null + server.use( + http.post(QUERY_URL, async ({ request }) => { + body = await request.json() + auth = request.headers.get('Authorization') + return HttpResponse.json(cfSuccess([{ results: [{ id: 7 }], success: true }])) + }) + ) + + const result = await callNonCodemodeTool(ACCOUNT_TOKEN, 'd1_read_query', { + database_id: DATABASE_ID, + sql: 'SELECT id FROM users WHERE email = ?', + params: ['a@example.com'] + }) + + expect(result.result?.isError).toBeFalsy() + expect(toolText(result)).toContain('"id": 7') + expect(body).toEqual({ sql: 'SELECT id FROM users WHERE email = ?', params: ['a@example.com'] }) + expect(auth).toBe(`Bearer ${ACCOUNT_TOKEN}`) + }) + + it('refuses SQL that writes without calling the Cloudflare API', async () => { + // No handler for the query endpoint: MSW fails the test on any request to it. + mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] }) + + const result = await callNonCodemodeTool(ACCOUNT_TOKEN, 'd1_read_query', { + database_id: DATABASE_ID, + sql: 'SELECT 1; DELETE FROM users' + }) + + expect(result.result?.isError).toBe(true) + expect(toolText(result)).toContain('Statement 2: DELETE statement is not read-only') + expect(toolText(result)).toContain('post_accounts_d1_database_query') + }) + + it('rejects unknown arguments through its strict input schema', async () => { + mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] }) + + const result = await callNonCodemodeTool(ACCOUNT_TOKEN, 'd1_read_query', { + database_id: DATABASE_ID, + sql: 'SELECT 1', + body: '{"sql":"DELETE FROM users"}' + }) + + expect(result.result?.isError).toBe(true) + expect(toolText(result)).toContain('Input validation error') + }) +}) diff --git a/tests/non-codemode.test.ts b/tests/non-codemode.test.ts index 4e150a2..5af65e2 100644 --- a/tests/non-codemode.test.ts +++ b/tests/non-codemode.test.ts @@ -272,7 +272,7 @@ describe('createServer with codemode=false', () => { expect(Object.keys((server as any)._registeredTools)).toEqual([]) const tools = await listTools(server) - expect(tools).toHaveLength(3_002) // docs + whoami + 3,000 endpoint tools + expect(tools).toHaveLength(3_003) // docs + whoami + d1_read_query + 3,000 endpoint tools }) it('registers one tool per endpoint when codemode=false', async () => { From 2d99d1562f4d41ff09e26d304f2e08068e0d7ff5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 21:38:59 +0000 Subject: [PATCH 2/2] feat(codemode): guard a read-only D1 query endpoint in execute 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 Claude-Session: https://claude.ai/code/session_019kuoB4UxMwTp2hrqUCyv63 --- AGENTS.md | 2 +- README.md | 4 +- scripts/seed-r2.ts | 4 +- src/index.ts | 4 +- src/tools/d1-read-query.ts | 9 +-- src/tools/d1-read-route.ts | 123 ++++++++++++++++++++++++++++++++++++ src/tools/d1-sql.ts | 20 +++--- src/tools/execute.ts | 12 +++- tests/d1-read-route.test.ts | 37 +++++++++++ tests/d1-sql.test.ts | 19 ++++++ tests/executor.test.ts | 56 ++++++++++++++++ tests/index.test.ts | 24 +++++++ 12 files changed, 293 insertions(+), 21 deletions(-) create mode 100644 src/tools/d1-read-route.ts create mode 100644 tests/d1-read-route.test.ts diff --git a/AGENTS.md b/AGENTS.md index b457599..09b1a2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,7 @@ The core innovation: instead of 2,500 MCP tools (~244K tokens), two tools handle `docs` (documentation search) and `whoami` are registered in both tool modes. -With `?codemode=false`, `d1_read_query` (`src/tools/d1-read-query.ts`) is listed next to the generated endpoint tools with `readOnlyHint: true`. `src/tools/d1-sql.ts` tokenizes the SQL with SQLite's own rules for comments, strings and quoted identifiers, and accepts it only when every statement is `SELECT`, `VALUES`, `WITH … SELECT`, `EXPLAIN` of one of those, or an allowlisted reporting `PRAGMA`. In SQLite the statement head decides this, because subqueries and CTE bodies can only be `SELECT`s. Refused SQL never reaches the API. The generated `post_accounts_d1_database_query` tool still runs any SQL. Don't swap the tokenizer for `sql-parser-cst`: it is GPL-2.0-or-later. `whoami` (`src/tools/whoami.ts`) returns the credential's identity as `user:` or `account:` and carries `_meta['openai/profile']` so ChatGPT can tell connected accounts apart. That ID format is a permanent contract: clients store it, so never change it. Its definition is static like every other tool's; only `tools/call` reads the credential. +With `?codemode=false`, `d1_read_query` (`src/tools/d1-read-query.ts`) is listed next to the generated endpoint tools with `readOnlyHint: true`. In Code Mode the same check guards a virtual `POST /accounts/{account_id}/d1/database/{database_id}/read_query` (`src/tools/d1-read-route.ts`): the scheduled handler adds it to `spec.json` after building `mcp-tools.json`, so `search` finds it but no endpoint tool calls the API path that doesn't exist, and `GlobalOutbound` refuses SQL that could write or sends the request to the real `.../query`. Both use `src/tools/d1-sql.ts`, which tokenizes the SQL with SQLite's own rules for comments, strings and quoted identifiers, and accepts it only when every statement is `SELECT`, `VALUES`, `WITH … SELECT`, `EXPLAIN` of one of those, or one of the reporting PRAGMAs D1 supports. In SQLite the statement head decides this, because subqueries and CTE bodies can only be `SELECT`s. The real query endpoint accepts a D1 Read token, so this check is the only thing separating reads from writes. Don't swap the tokenizer for `sql-parser-cst`: it is GPL-2.0-or-later. ### MCP HTTP serving diff --git a/README.md b/README.md index bc46e51..9b6207c 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,9 @@ Create a [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens) If your MCP client already uses code mode, or you're composing this server with another server that uses code mode, you can disable it with the `?codemode=false` query parameter. This registers an individual tool for each of the ~2,500 Cloudflare API endpoints instead of the code mode API tools. The `docs` and `whoami` tools remain available in both modes. -This mode also adds `d1_read_query`, which runs SQL against a D1 database only when every statement is a read: `SELECT`, `VALUES`, `WITH … SELECT`, `EXPLAIN` of one of those, or a schema-reading `PRAGMA` such as `table_info`. Anything else is refused before it reaches D1. It is annotated `readOnlyHint: true`, so a client can approve it without asking. `post_accounts_d1_database_query` still runs any SQL. +This mode also adds `d1_read_query`, which runs SQL against a D1 database only when every statement is a read: `SELECT`, `VALUES`, `WITH … SELECT`, `EXPLAIN` of one of those, or one of the reporting PRAGMAs D1 supports, such as `table_info`. Anything else is refused before it reaches D1. It is annotated `readOnlyHint: true`, so a client can approve it without asking. `post_accounts_d1_database_query` still runs any SQL. + +In code mode the same check guards `POST /accounts/{account_id}/d1/database/{database_id}/read_query`, an endpoint this server adds next to `.../query`. `search` finds it and `execute` calls it like any other path. A client can't approve it separately, because `execute` is one tool and can still call `.../query`. ``` https://mcp.cloudflare.com/mcp?codemode=false diff --git a/scripts/seed-r2.ts b/scripts/seed-r2.ts index aec83fe..943275e 100644 --- a/scripts/seed-r2.ts +++ b/scripts/seed-r2.ts @@ -3,6 +3,7 @@ import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { processSpec, extractProducts } from '../src/spec-processor' +import { withD1ReadQueryOperation } from '../src/tools/d1-read-route' import { MCP_TOOLS_KEY, buildMcpTools, type OperationInfo } from '../src/openapi' import { fetchSkills } from '../src/skills/sync' import { SKILLS_MANIFEST_KEY, skillFileKey } from '../src/skills/types' @@ -26,7 +27,8 @@ const rawSpec = (await response.json()) as Record console.log('Processing spec, resolving $refs...') const processed = processSpec(rawSpec) -const specJson = JSON.stringify(processed) +// Code Mode's read-only D1 endpoint is searchable but has no endpoint tool. +const specJson = JSON.stringify({ paths: withD1ReadQueryOperation(processed.paths) }) const products = extractProducts(rawSpec) const productsJson = JSON.stringify(products) diff --git a/src/index.ts b/src/index.ts index 4d895ac..f583211 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { rejectInvalidMcpRequest } from './mcp-handler' import { processSpec, extractProducts } from './spec-processor' +import { withD1ReadQueryOperation } from './tools/d1-read-route' import { MCP_TOOLS_KEY, buildMcpTools, type OperationInfo } from './openapi' import { syncSkills } from './skills/sync' @@ -124,7 +125,8 @@ async function syncSpec(env: Env): Promise { console.log('Processing spec, resolving $refs...') const processed = processSpec(rawSpec) - const specJson = JSON.stringify(processed) + // Code Mode's read-only D1 endpoint is searchable but has no endpoint tool. + const specJson = JSON.stringify({ paths: withD1ReadQueryOperation(processed.paths) }) const products = extractProducts(rawSpec) const productsJson = JSON.stringify(products) diff --git a/src/tools/d1-read-query.ts b/src/tools/d1-read-query.ts index f5b1e37..8b33d11 100644 --- a/src/tools/d1-read-query.ts +++ b/src/tools/d1-read-query.ts @@ -11,7 +11,7 @@ import { } from '../auth/account-access' import { ACCOUNT_ID_PARAM_DESCRIPTION } from '../openapi' import type { AuthProps } from '../auth/types' -import { classifyD1Sql } from './d1-sql' +import { READ_ONLY_SQL_SUMMARY, classifyD1Sql } from './d1-sql' const NAME = 'd1_read_query' const TITLE = 'Read-Only D1 Query' @@ -21,8 +21,7 @@ const WRITE_TOOL_NAME = 'post_accounts_d1_database_query' const DESCRIPTION = 'Run read-only SQL against a D1 database (POST /accounts/{account_id}/d1/database/{database_id}/query). ' + - 'Every statement must be SELECT, VALUES, WITH … SELECT, EXPLAIN [QUERY PLAN] of one of those, or a ' + - 'schema-reading PRAGMA such as table_list or table_info; anything else is refused before it reaches D1. ' + + `Every statement must be ${READ_ONLY_SQL_SUMMARY}; anything else is refused before it reaches D1. ` + `Separate several statements with semicolons. Use ${WRITE_TOOL_NAME} to change data or schema.` export const D1ReadQueryInputSchema = z.strictObject({ @@ -55,7 +54,9 @@ export const D1_READ_QUERY_TOOL: Tool = { /** * Run `d1_read_query`: refuse SQL that could write, then send the rest to D1's - * query endpoint. + * query endpoint. The endpoint accepts a token with only D1 Read, so + * Cloudflare's permissions don't separate reads from writes on it; this check + * is what makes the tool read-only. * * @param input - The validated tool arguments. * @param props - The request's validated credential. diff --git a/src/tools/d1-read-route.ts b/src/tools/d1-read-route.ts new file mode 100644 index 0000000..779a445 --- /dev/null +++ b/src/tools/d1-read-route.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import { READ_ONLY_SQL_SUMMARY, classifyD1Sql } from './d1-sql' + +/** + * A read-only D1 query endpoint that only this server provides, for Code + * Mode. `search` finds it in `spec.json` next to the real query endpoint, and + * `execute` calls it with `cloudflare.request()` like any other path. The + * outbound proxy refuses SQL that could write and sends the rest to the real + * endpoint. The real endpoint accepts a token with only D1 Read, so + * Cloudflare's permissions don't separate reads from writes on it. + */ +export const D1_READ_QUERY_PATH = '/accounts/{account_id}/d1/database/{database_id}/read_query' + +const D1_QUERY_PATH = '/accounts/{account_id}/d1/database/{database_id}/query' + +const ROUTE = /^\/accounts\/([^/]+)\/d1\/database\/([^/]+)\/read_query\/?$/i + +const D1_READ_QUERY_DESCRIPTION = + 'Provided by this MCP server, not the Cloudflare API. Runs SQL only when every statement is ' + + `${READ_ONLY_SQL_SUMMARY}, then sends the request unchanged to POST ${D1_QUERY_PATH}. ` + + 'Anything else is refused before it reaches D1. ' + + `Use POST ${D1_QUERY_PATH} to change data or schema.` + +const QuerySchema = z.strictObject({ sql: z.string(), params: z.array(z.unknown()).optional() }) + +/** + * The query endpoint's two body forms. Both are strict: a key D1 might act on + * but this check doesn't read, such as a `batch` next to `sql`, is refused. + */ +const BodySchema = z.union([QuerySchema, z.strictObject({ batch: z.array(QuerySchema).min(1) })]) + +/** + * Adds the read-only endpoint to the spec `search` sees. It copies the real + * query endpoint's parameters, body and responses, and is left out when the + * spec has no query endpoint. `mcp-tools.json` is built without it, because + * endpoint tools call the Cloudflare API directly rather than through the + * outbound proxy. + * + * @param paths - The processed spec's paths. + * @returns The paths with the read-only endpoint added. + */ +export function withD1ReadQueryOperation( + paths: Record> +): Record> { + const query = paths[D1_QUERY_PATH]?.post as Record | undefined + if (!query) return paths + return { + ...paths, + [D1_READ_QUERY_PATH]: { + post: { + ...query, + summary: 'Query D1 Database (read-only)', + description: D1_READ_QUERY_DESCRIPTION + } + } + } +} + +/** + * Routes a request from the `execute` isolate that targets the read-only + * endpoint. + * + * @param request - The isolate's outbound request, already checked to be for the API host. + * @param apiBase - The Cloudflare API base URL, such as `https://api.cloudflare.com/client/v4`. + * @returns `undefined` for any other path, a refusal for SQL that could write, + * or the same request sent to the real query endpoint. + */ +export async function routeD1ReadQuery( + request: Request, + apiBase: string +): Promise { + const url = new URL(request.url) + const basePath = new URL(apiBase).pathname.replace(/\/+$/, '') + if (!url.pathname.startsWith(`${basePath}/`)) return undefined + + const match = ROUTE.exec(url.pathname.slice(basePath.length)) + if (!match) return undefined + + if (request.method.toUpperCase() !== 'POST') { + return refusal(405, `${request.method} is not supported. Use POST ${D1_READ_QUERY_PATH}.`) + } + + const body = await request.text() + const problem = bodyProblem(body) + if (problem) return refusal(400, problem) + + const [, accountId, databaseId] = match + url.pathname = `${basePath}/accounts/${accountId}/d1/database/${databaseId}/query` + return new Request(url, { method: 'POST', headers: request.headers, body }) +} + +/** Why the body can't be sent, or `undefined` when every statement only reads. */ +function bodyProblem(body: string): string | undefined { + let json: unknown + try { + json = JSON.parse(body) + } catch { + return 'Refused: the body is not JSON. Send { sql, params } or { batch: [{ sql, params }] }.' + } + + const parsed = BodySchema.safeParse(json) + if (!parsed.success) { + return 'Refused: send { sql, params } or { batch: [{ sql, params }] } with no other keys.' + } + + const queries = 'batch' in parsed.data ? parsed.data.batch : [parsed.data] + for (const [index, query] of queries.entries()) { + const verdict = classifyD1Sql(query.sql) + if (!verdict.readOnly) { + const which = queries.length > 1 ? `batch[${index}]: ` : '' + return `Refused: ${which}${verdict.reason} Use POST ${D1_QUERY_PATH} for other SQL.` + } + } + return undefined +} + +/** A refusal in the Cloudflare API's error envelope, which `cloudflare.request()` throws as an error. */ +function refusal(status: number, message: string): Response { + return Response.json( + { success: false, errors: [{ code: status, message }], messages: [], result: null }, + { status } + ) +} diff --git a/src/tools/d1-sql.ts b/src/tools/d1-sql.ts index 671d350..8611cf6 100644 --- a/src/tools/d1-sql.ts +++ b/src/tools/d1-sql.ts @@ -21,30 +21,28 @@ type Token = | { kind: 'other' } /** - * PRAGMAs that only report. Each reads the schema or checks integrity and - * changes nothing, with or without its argument. The PRAGMAs that set a value - * also accept `PRAGMA name(value)`, so they are left out rather than told - * apart by syntax. + * The PRAGMAs D1 supports that only report: they read the schema or check + * integrity, with or without an argument. D1's other PRAGMAs are toggles that + * also accept `PRAGMA name(value)`, or `optimize`, which runs ANALYZE, so they + * are left out rather than told apart by syntax. + * + * @see https://developers.cloudflare.com/d1/sql-api/sql-statements/#compatible-pragma-statements */ const READ_ONLY_PRAGMAS = new Set([ - 'COLLATION_LIST', - 'COMPILE_OPTIONS', - 'DATABASE_LIST', 'FOREIGN_KEY_CHECK', 'FOREIGN_KEY_LIST', - 'FUNCTION_LIST', 'INDEX_INFO', 'INDEX_LIST', 'INDEX_XINFO', - 'INTEGRITY_CHECK', - 'MODULE_LIST', - 'PRAGMA_LIST', 'QUICK_CHECK', 'TABLE_INFO', 'TABLE_LIST', 'TABLE_XINFO' ]) +/** What the check accepts, for tool and endpoint descriptions. */ +export const READ_ONLY_SQL_SUMMARY = `SELECT, VALUES, WITH … SELECT, EXPLAIN [QUERY PLAN] of one of those, or a PRAGMA that only reports (${[...READ_ONLY_PRAGMAS].map((pragma) => pragma.toLowerCase()).join(', ')})` + /** Keywords that can start the main statement after a WITH clause. */ const WITH_MAIN_KEYWORDS = new Set(['SELECT', 'VALUES', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE']) diff --git a/src/tools/execute.ts b/src/tools/execute.ts index 84e2f40..a5f576a 100644 --- a/src/tools/execute.ts +++ b/src/tools/execute.ts @@ -13,6 +13,7 @@ import { unknownAccountHint } from '../auth/account-access' import type { AuthProps } from '../auth/types' +import { routeD1ReadQuery } from './d1-read-route' interface CodeExecutorEntrypoint { evaluate(): Promise<{ result: unknown; err?: string; stack?: string }> @@ -36,10 +37,15 @@ export class GlobalOutbound extends WorkerEntrypoint { if (requested !== allowed) { return new Response(`Forbidden: requests to ${requested} are not allowed`, { status: 403 }) } + // The read-only D1 endpoint is this server's, not the API's: refuse SQL + // that could write, and send the rest to the real query endpoint. + const routed = await routeD1ReadQuery(request, this.env.CLOUDFLARE_API_BASE) + if (routed instanceof Response) return routed + const upstream = routed ?? request // Inject auth header — token comes from props, never enters user code isolate - const authedRequest = new Request(request, { + const authedRequest = new Request(upstream, { headers: new Headers([ - ...request.headers.entries(), + ...upstream.headers.entries(), ['Authorization', `Bearer ${this.ctx.props.apiToken}`] ]) }) @@ -219,6 +225,8 @@ When the session has access to multiple accounts, pass account_id. ${ACCOUNT_DIS Your code must be an async arrow function that returns the result. +To only read a D1 database, POST { sql, params } to /accounts/{account_id}/d1/database/{database_id}/read_query instead of .../query: this server refuses SQL that could write before it reaches D1. + Example: Worker with bindings (requires multipart/form-data): async () => { const code = \`addEventListener('fetch', e => e.respondWith(MY_KV.get('key').then(v => new Response(v || 'none'))));\`; diff --git a/tests/d1-read-route.test.ts b/tests/d1-read-route.test.ts new file mode 100644 index 0000000..bf0bc50 --- /dev/null +++ b/tests/d1-read-route.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { D1_READ_QUERY_PATH, withD1ReadQueryOperation } from '../src/tools/d1-read-route' + +const QUERY_PATH = '/accounts/{account_id}/d1/database/{database_id}/query' + +describe('withD1ReadQueryOperation', () => { + const paths = { + [QUERY_PATH]: { + post: { + summary: 'Query D1 Database', + tags: ['D1'], + parameters: [{ name: 'account_id', in: 'path', required: true }], + requestBody: { required: true, content: { 'application/json': {} } } + } + } + } + + it('adds a searchable read-only copy of the query endpoint', () => { + const operation = withD1ReadQueryOperation(paths)[D1_READ_QUERY_PATH]?.post as Record< + string, + unknown + > + + expect(operation).toMatchObject({ + summary: 'Query D1 Database (read-only)', + tags: ['D1'], + requestBody: paths[QUERY_PATH].post.requestBody + }) + expect(operation.description).toContain('not the Cloudflare API') + expect(paths).not.toHaveProperty(D1_READ_QUERY_PATH) + }) + + it('leaves a spec without the query endpoint unchanged', () => { + const other = { '/accounts': { get: { summary: 'List accounts' } } } + expect(withD1ReadQueryOperation(other)).toBe(other) + }) +}) diff --git a/tests/d1-sql.test.ts b/tests/d1-sql.test.ts index aeeefaa..7cacdd3 100644 --- a/tests/d1-sql.test.ts +++ b/tests/d1-sql.test.ts @@ -54,3 +54,22 @@ describe('classifyD1Sql', () => { expect(verdict.readOnly === false && verdict.reason).toContain(reason) }) }) + +describe('classifyD1Sql PRAGMAs', () => { + it.each(['integrity_check', 'case_sensitive_like', 'defer_foreign_keys', 'compile_options'])( + 'rejects PRAGMA %s, which D1 does not list as reporting', + (name) => { + expect(classifyD1Sql(`PRAGMA ${name}`).readOnly).toBe(false) + } + ) + + it.each([ + 'table_xinfo(t)', + 'index_xinfo(i)', + 'quick_check', + 'foreign_key_check', + 'foreign_key_list(t)' + ])('accepts PRAGMA %s', (pragma) => { + expect(classifyD1Sql(`PRAGMA ${pragma}`)).toEqual({ readOnly: true }) + }) +}) diff --git a/tests/executor.test.ts b/tests/executor.test.ts index da2559e..9301204 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -393,3 +393,59 @@ describe('search: real SPEC_BUCKET', () => { expect(result.result?.isError).toBe(true) }) }) + +describe('execute: read-only D1 endpoint', () => { + const DATABASE_ID = 'd1d1d1d1-0000-0000-0000-000000000001' + const READ_PATH = `/accounts/${ACCOUNT_ID}/d1/database/${DATABASE_ID}/read_query` + const QUERY_URL = `${API_BASE}/accounts/${ACCOUNT_ID}/d1/database/${DATABASE_ID}/query` + + beforeEach(() => mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] })) + + function post(body: unknown, method = 'POST'): Promise { + return callTool(API_TOKEN, 'execute', { + code: `async () => cloudflare.request({ method: ${JSON.stringify(method)}, path: ${JSON.stringify(READ_PATH)}, body: ${JSON.stringify(body)} })` + }).then(toolText) + } + + it('sends read-only SQL unchanged to the real query endpoint with the token', async () => { + let received: unknown + let auth: string | null = null + server.use( + http.post(QUERY_URL, async ({ request }) => { + received = await request.json() + auth = request.headers.get('Authorization') + return HttpResponse.json(cfSuccess([{ results: [{ n: 1 }], success: true }])) + }) + ) + + const text = await post({ sql: 'SELECT ? AS n', params: ['1'] }) + + expect(text).toContain('"n": 1') + expect(received).toEqual({ sql: 'SELECT ? AS n', params: ['1'] }) + expect(auth).toBe(`Bearer ${API_TOKEN}`) + }) + + it('checks every query in a batch', async () => { + // No handler for the query endpoint: MSW fails the test on any request to it. + const text = await post({ batch: [{ sql: 'SELECT 1' }, { sql: 'DROP TABLE users' }] }) + + expect(text).toContain('Refused: batch[1]: DROP statement is not read-only') + expect(text).toContain('/query for other SQL') + }) + + it.each([ + [{ sql: 'SELECT 1; DELETE FROM users' }, 'Statement 2: DELETE'], + [{ sql: 'SELECT 1', batch: [{ sql: 'DELETE FROM users' }] }, 'with no other keys'], + [{ sql: 'PRAGMA foreign_keys = OFF' }, 'Only these read-only PRAGMAs'] + ])('refuses %j before it reaches D1', async (body, reason) => { + expect(await post(body)).toContain(reason) + }) + + it('refuses methods other than POST', async () => { + const text = await callTool(API_TOKEN, 'execute', { + code: `async () => cloudflare.request({ method: "GET", path: ${JSON.stringify(READ_PATH)} })` + }).then(toolText) + + expect(text).toContain('GET is not supported') + }) +}) diff --git a/tests/index.test.ts b/tests/index.test.ts index b585bc9..98d8d2c 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -90,6 +90,30 @@ describe('scheduled handler', () => { expect(tools[0].inputSchema.required).toBeUndefined() }) + it("adds Code Mode's read-only D1 endpoint to spec.json but not to mcp-tools.json", async () => { + const query = '/accounts/{account_id}/d1/database/{database_id}/query' + const readQuery = '/accounts/{account_id}/d1/database/{database_id}/read_query' + server.use( + http.get(SPEC_URL, () => + HttpResponse.json({ + ...RAW_SPEC, + paths: { [query]: { post: { summary: 'Query D1 Database', tags: ['D1'] } } } + }) + ) + ) + + await runScheduled() + + const spec = (await (await env.SPEC_BUCKET.get('spec.json'))!.json()) as { + paths: Record + } + const tools = (await (await env.SPEC_BUCKET.get('mcp-tools.json'))!.json()) as Array<{ + path: string + }> + expect(Object.keys(spec.paths)).toEqual([query, readQuery]) + expect(tools.map((tool) => tool.path)).toEqual([query]) + }) + it('throws and writes no spec artifacts when GitHub returns a non-2xx', async () => { server.use(http.get(SPEC_URL, () => new HttpResponse('Not Found', { status: 404 })))