diff --git a/AGENTS.md b/AGENTS.md index f5756e2..09b1a2d 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`. 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 dbdc2b1..9b6207c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ 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 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 new file mode 100644 index 0000000..8b33d11 --- /dev/null +++ b/src/tools/d1-read-query.ts @@ -0,0 +1,107 @@ +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 { READ_ONLY_SQL_SUMMARY, 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 ${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({ + 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. 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. + * @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-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 new file mode 100644 index 0000000..8611cf6 --- /dev/null +++ b/src/tools/d1-sql.ts @@ -0,0 +1,268 @@ +/** + * 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' } + +/** + * 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([ + 'FOREIGN_KEY_CHECK', + 'FOREIGN_KEY_LIST', + 'INDEX_INFO', + 'INDEX_LIST', + 'INDEX_XINFO', + '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']) + +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/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/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-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 new file mode 100644 index 0000000..7cacdd3 --- /dev/null +++ b/tests/d1-sql.test.ts @@ -0,0 +1,75 @@ +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) + }) +}) + +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 }))) 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 () => {