Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
APP_BASE_URL: ${{ vars.APP_BASE_URL }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
node tools/ci/production-resources.ts ensure --out-config packages/worker/wrangler-production.generated.json | tee -a "$GITHUB_OUTPUT"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ jobs:
id: resources
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
run: |
set -euo pipefail
Expand Down
9 changes: 7 additions & 2 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,13 @@ Optional Worker secrets/vars (see `packages/worker/src/env-schema.ts` and
- `CLOUDFLARE_API_TOKEN` — Cloudflare API token used by the `cloudflare_rest`
capability with `Authorization: Bearer ...`. Local `npm run dev` sets this to
the Cloudflare mock token unless `AI_MODE=remote` or `SKIP_CLOUDFLARE_MOCK=1`;
when unset and no mock is attached, `cloudflare_rest` fails fast with a setup
hint.
when unset and no mock is attached, `cloudflare_rest` and the billed
`page_to_markdown` Browser Rendering fallback fail fast with a setup hint.
- `CLOUDFLARE_ACCOUNT_ID` — Cloudflare account id required by the
`page_to_markdown` capability when it falls back to Browser Rendering
`POST /client/v4/accounts/{account_id}/browser-rendering/markdown`. This is a
Worker var (not a secret) and should match the account behind
`CLOUDFLARE_API_TOKEN`.
- `CLOUDFLARE_API_BASE_URL` — API base URL; defaults to
`https://api.cloudflare.com` when unset. Local `npm run dev` sets this to the
Cloudflare mock Worker unless `AI_MODE=remote` or `SKIP_CLOUDFLARE_MOCK=1`.
Expand Down
11 changes: 8 additions & 3 deletions docs/setup-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ automatically:
gateway ID from GitHub Actions secrets so remote inference goes through
Cloudflare AI Gateway)
- `CLOUDFLARE_ACCOUNT_ID` (required for local development when `AI_MODE=remote`
so Wrangler can authenticate Workers AI requests against the correct account)
so Wrangler can authenticate Workers AI requests against the correct account;
also required when using the `page_to_markdown` capability's Cloudflare
Browser Rendering fallback against the live API)
- `CLOUDFLARE_API_TOKEN` (required for local development when `AI_MODE=remote`
so Wrangler can authenticate Workers AI requests)
- `SENTRY_DSN` (optional Cloudflare Worker secret; enables error reporting and
Expand Down Expand Up @@ -173,10 +175,13 @@ How to get/set each value:
`SENTRY_ORG` and `SENTRY_PROJECT` with your Sentry slugs (for example from
`npx @sentry/wizard@latest -i sourcemaps`).
- `CLOUDFLARE_API_TOKEN` (optional for `cloudflare_rest`, required for remote
AI)
AI, and reused by the `page_to_markdown` capability when it falls back to
Cloudflare Browser Rendering `/markdown`)
- Create a Cloudflare API token with the account permissions needed for the
product APIs you want to call. This same secret already powers production
deploys and can also be used by the `cloudflare_rest` MCP capability.
deploys and can also be used by the `cloudflare_rest` and
`page_to_markdown` MCP capabilities. For Browser Rendering fallback, include
the **Browser Rendering - Edit** permission.
- `CAPABILITY_REINDEX_SECRET` (optional)
- Generate a long random secret (for example `openssl rand -hex 32`), store it
as the repository secret `CAPABILITY_REINDEX_SECRET`, and let the deploy
Expand Down
97 changes: 97 additions & 0 deletions packages/mock-servers/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ type DnsRecord = {
ttl: number
}

type BrowserRenderingMarkdownBody = {
url?: unknown
html?: unknown
userAgent?: unknown
rejectRequestPattern?: unknown
gotoOptions?: unknown
}

const fixtureAccount = {
id: 'cf_account_mock_123',
name: 'Mock Account',
Expand Down Expand Up @@ -142,6 +150,11 @@ function handleMeta(request: Request, env: MockCloudflareEnv) {
path: `/client/v4/zones/${fixtureZone.id}/dns_records`,
description: 'Create DNS record',
},
{
method: 'POST',
path: `/client/v4/accounts/${fixtureAccount.id}/browser-rendering/markdown`,
description: 'Convert a page or HTML snippet to markdown',
},
],
})
}
Expand All @@ -152,6 +165,10 @@ function handleDashboard() {
['GET', '/client/v4/zones'],
['GET', `/client/v4/zones/${fixtureZone.id}/dns_records`],
['POST', `/client/v4/zones/${fixtureZone.id}/dns_records`],
[
'POST',
`/client/v4/accounts/${fixtureAccount.id}/browser-rendering/markdown`,
],
]
const rows = endpoints
.map(
Expand All @@ -173,6 +190,24 @@ function handleDashboard() {
}

async function routeApi(request: Request, env: MockCloudflareEnv, url: URL) {
if (request.method === 'GET' && url.pathname === '/__mocks/markdown') {
return new Response('# Mock markdown\n\nServed as markdown.\n', {
headers: {
'content-type': 'text/markdown; charset=utf-8',
'x-markdown-tokens': '8',
},
})
}
if (request.method === 'GET' && url.pathname === '/__mocks/markdown-error') {
return new Response('# Mock markdown error\n\nServer error page.\n', {
status: 500,
headers: {
'content-type': 'text/markdown; charset=utf-8',
'x-markdown-tokens': '9',
},
})
}

if (!isAuthorized(request, env)) {
return unauthorized()
}
Expand Down Expand Up @@ -219,6 +254,68 @@ async function routeApi(request: Request, env: MockCloudflareEnv, url: URL) {
)
}

const markdownMatch = url.pathname.match(
/^\/client\/v4\/accounts\/([^/]+)\/browser-rendering\/markdown\/?$/,
)
if (markdownMatch && request.method === 'POST') {
const accountId = markdownMatch[1]!
const payload = (await readJsonBody(
request,
)) as BrowserRenderingMarkdownBody | null
if (payload === null) {
return json(
{
success: false,
errors: [{ code: 1001, message: 'invalid JSON body' }],
messages: [],
result: null,
},
{ status: 400 },
)
}
if (accountId !== fixtureAccount.id) {
return json(
{
success: false,
errors: [{ code: 1002, message: 'account not found' }],
messages: [],
result: null,
},
{ status: 404 },
)
}
const hasUrl = typeof payload.url === 'string' && payload.url.trim().length > 0
const hasHtml =
typeof payload.html === 'string' && payload.html.trim().length > 0
if (!hasUrl && !hasHtml) {
return json(
{
success: false,
errors: [
{ code: 1003, message: 'Either url or html is required.' },
],
messages: [],
result: null,
},
{ status: 400 },
)
}
const mode = hasHtml ? 'html' : 'url'
const sourceValue = hasHtml
? String(payload.html).trim()
: String(payload.url).trim()
const markdown = [
'# Mock Browser Rendering',
'',
`mode: ${mode}`,
`source: ${sourceValue}`,
...(typeof payload.userAgent === 'string' && payload.userAgent.length > 0
? [`userAgent: ${payload.userAgent}`]
: []),
].join('\n')
return envelope(markdown, { status: 200 })
}

const dnsListMatch = url.pathname.match(
/^\/client\/v4\/zones\/([^/]+)\/dns_records\/?$/,
)
Expand Down
3 changes: 2 additions & 1 deletion packages/worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ RESEND_FROM_EMAIL=reset@kody.dev
# SENTRY_ORG=your-org-slug
# SENTRY_PROJECT=your-project-slug

# Cloudflare API (optional; `npm run dev` sets a mock URL + token for `cloudflare_rest`)
# Cloudflare API / Browser Rendering (optional; `npm run dev` sets a mock URL + token for `cloudflare_rest`)
# Uses Authorization: Bearer <token> against /client/v4/* endpoints.
# Reuse `CLOUDFLARE_API_TOKEN` for the live API. In mock mode `npm run dev` injects a local token.
# page_to_markdown also needs CLOUDFLARE_ACCOUNT_ID when it falls back to Cloudflare Browser Rendering `/markdown`.
# CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com
# Skip starting the Cloudflare mock during dev (use live API + token in `packages/worker/.env` instead):
# SKIP_CLOUDFLARE_MOCK=1
Expand Down
1 change: 1 addition & 0 deletions packages/worker/src/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const EnvSchema = object({
SENTRY_DSN: optionalUrlStringSchema,
SENTRY_ENVIRONMENT: optionalNonEmptyStringSchema,
SENTRY_TRACES_SAMPLE_RATE: optionalSentryTracesSampleRateSchema,
CLOUDFLARE_ACCOUNT_ID: optionalNonEmptyStringSchema,
CLOUDFLARE_API_TOKEN: optionalNonEmptyStringSchema,
CLOUDFLARE_API_BASE_URL: optionalUrlStringSchema,
CAPABILITY_REINDEX_SECRET: optionalNonEmptyStringSchema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
wranglerBin,
} from '#mcp/test-process.ts'
import { cloudflareRestCapability } from './cloudflare-rest.ts'
import { pageToMarkdownCapability } from './page-to-markdown.ts'

const cloudflareWorkerConfig = 'packages/mock-servers/cloudflare/wrangler.jsonc'
const projectRoot = process.cwd()
Expand Down Expand Up @@ -68,6 +69,7 @@ async function startCloudflareMock(token: string) {

function mockCloudflareContext(origin: string, token: string) {
const env = {
CLOUDFLARE_ACCOUNT_ID: 'cf_account_mock_123',
CLOUDFLARE_API_TOKEN: token,
CLOUDFLARE_API_BASE_URL: origin,
} as Env
Expand Down Expand Up @@ -116,3 +118,108 @@ test('cloudflare_rest rejects paths not under /client/v4/', async () => {
),
).rejects.toThrow('path must start with `/client/v4/`')
})

test('page_to_markdown returns negotiated markdown without Browser Rendering', async () => {
const token = 'coding-cloudflare-page-negotiated-token'
await using mock = await startCloudflareMock(token)
const ctx = mockCloudflareContext(mock.origin, mock.token)
const result = await pageToMarkdownCapability.handler(
{
url: `${mock.origin}/__mocks/markdown`,
},
ctx,
)
expect(result.source).toBe('negotiated')
expect(result.browserRendering).toBeNull()
expect(result.negotiated?.contentType).toContain('text/markdown')
expect(result.markdown).toContain('# Mock markdown')
})

test('page_to_markdown does not treat markdown error responses as negotiated success', async () => {
const token = 'coding-cloudflare-page-error-token'
await using mock = await startCloudflareMock(token)
const ctx = mockCloudflareContext(mock.origin, mock.token)
const result = await pageToMarkdownCapability.handler(
{
url: `${mock.origin}/__mocks/markdown-error`,
},
ctx,
)
expect(result.source).toBe('browser_rendering')
expect(result.negotiated?.status).toBe(500)
expect(result.negotiated?.contentType).toContain('text/markdown')
expect(result.browserRendering).toEqual({
apiStatus: 200,
mode: 'url',
})
expect(result.markdown).toContain('# Mock Browser Rendering')
expect(result.markdown).toContain(`source: ${mock.origin}/__mocks/markdown-error`)
})

test('page_to_markdown falls back to Browser Rendering for html pages', async () => {
const token = 'coding-cloudflare-page-markdown-token'
await using mock = await startCloudflareMock(token)
const ctx = mockCloudflareContext(mock.origin, mock.token)
const result = await pageToMarkdownCapability.handler(
{
url: `${mock.origin}/__mocks`,
userAgent: 'kody-test-agent',
},
ctx,
)
expect(result.source).toBe('browser_rendering')
expect(result.negotiated?.contentType).toContain('text/html')
expect(result.browserRendering).toEqual({
apiStatus: 200,
mode: 'url',
})
expect(result.markdown).toContain('# Mock Browser Rendering')
expect(result.markdown).toContain(`source: ${mock.origin}/__mocks`)
expect(result.markdown).toContain('userAgent: kody-test-agent')
})

test('page_to_markdown converts inline html with Browser Rendering', async () => {
const token = 'coding-cloudflare-page-inline-token'
await using mock = await startCloudflareMock(token)
const ctx = mockCloudflareContext(mock.origin, mock.token)
const result = await pageToMarkdownCapability.handler(
{
html: '<main><h1>Hello</h1><p>From HTML</p></main>',
},
ctx,
)
expect(result.source).toBe('browser_rendering')
expect(result.url).toBeNull()
expect(result.negotiated).toBeNull()
expect(result.browserRendering).toEqual({
apiStatus: 200,
mode: 'html',
})
expect(result.markdown).toContain('mode: html')
expect(result.markdown).toContain(
'source: <main><h1>Hello</h1><p>From HTML</p></main>',
)
})

test('page_to_markdown fails clearly when Browser Rendering account id is missing', async () => {
const token = 'coding-cloudflare-page-no-account-token'
await using mock = await startCloudflareMock(token)
const ctx = {
env: {
CLOUDFLARE_API_TOKEN: token,
CLOUDFLARE_API_BASE_URL: mock.origin,
} as Env,
callerContext: {
baseUrl: 'http://localhost:3742',
user: null,
},
}
await expect(
pageToMarkdownCapability.handler(
{
url: `${mock.origin}/__mocks`,
},
ctx,
),
).rejects.toThrow('CLOUDFLARE_ACCOUNT_ID is not set')
})
4 changes: 3 additions & 1 deletion packages/worker/src/mcp/capabilities/coding/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import { cloudflareApiDocsCapability } from './cloudflare-api-docs.ts'
import { cloudflareRestCapability } from './cloudflare-rest.ts'
import { generatedUiOAuthGuideCapability } from './generated-ui-oauth-guide.ts'
import { generatedUiSecretGuideCapability } from './generated-ui-secret-guide.ts'
import { pageToMarkdownCapability } from './page-to-markdown.ts'

export const codingDomain = defineDomain({
name: capabilityDomainNames.coding,
description:
'Software work such as Cloudflare API calls, public Cloudflare documentation fetch (markdown), generated UI guides, and coding-agent workflows.',
'Software work such as Cloudflare API calls, public Cloudflare documentation fetch (markdown), billed page-to-markdown fallback for hard-to-read web pages, generated UI guides, and coding-agent workflows. Prefer normal fetch, browser tools, or host-specific docs capabilities before the billed fallback.',
capabilities: [
generatedUiOAuthGuideCapability,
generatedUiSecretGuideCapability,
cloudflareRestCapability,
cloudflareApiDocsCapability,
pageToMarkdownCapability,
],
})
Loading
Loading