Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` or `account:<id>` 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

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
4 changes: 3 additions & 1 deletion scripts/seed-r2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -26,7 +27,8 @@ const rawSpec = (await response.json()) as Record<string, unknown>
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)
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -124,7 +125,8 @@ async function syncSpec(env: Env): Promise<void> {
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)
Expand Down
107 changes: 107 additions & 0 deletions src/tools/d1-read-query.ts
Original file line number Diff line number Diff line change
@@ -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<typeof D1ReadQueryInputSchema>

/**
* 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<CallToolResult> {
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 }
}
123 changes: 123 additions & 0 deletions src/tools/d1-read-route.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>
): Record<string, Record<string, unknown>> {
const query = paths[D1_QUERY_PATH]?.post as Record<string, unknown> | 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<Request | Response | undefined> {
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 }
)
}
Loading