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
124 changes: 124 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: Preview

on:
pull_request_target:
types: [labeled, synchronize, closed]

permissions:
contents: read
pull-requests: write

concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: false

env:
WRANGLER: wrangler@4.149.0
PR_NUMBER: ${{ github.event.pull_request.number }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.PREVIEW_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.PREVIEW_DEPLOY_TOKEN }}

jobs:
require-review:
name: Require a new review after each push
if: github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'preview')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
with:
script: |
await github.rest.issues.removeLabel({ ...context.repo, issue_number: context.issue.number, name: 'preview' })
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: 'New commits were pushed. A maintainer must review them and add the `preview` label again to update the preview.'
})

deploy:
name: Deploy preview
if: github.event.action == 'labeled' && github.event.label.name == 'preview'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- uses: actions/setup-node@v6
with:
node-version: "22"

- name: Install dependencies
run: npm ci --ignore-scripts

- uses: actions/checkout@v6
with:
path: base
persist-credentials: false

- name: Write preview config
id: config
run: |
mkdir -p .preview
node base/scripts/preview-config.mjs "$PR_NUMBER" "${{ vars.PREVIEW_DOMAIN_SUFFIX }}" "${{ vars.PREVIEW_KV_ID }}" "${{ vars.PREVIEW_BUCKET }}" > .preview/out.json
echo "url=$(node -p "require('./.preview/out.json').url")" >> "$GITHUB_OUTPUT"

- name: Deploy
run: |
npx --yes "$WRANGLER" deploy -c .preview/wrangler.json
openssl rand -hex 32 | npx --yes "$WRANGLER" secret put MCP_COOKIE_ENCRYPTION_KEY -c .preview/wrangler.json

- name: Check the deployed server
env:
PREVIEW_URL: ${{ steps.config.outputs.url }}
run: |
for _ in $(seq 1 18); do
status=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$PREVIEW_URL" \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"preview-check","version":"1"}}}' || true)
[ "$status" = "401" ] && exit 0
sleep 10
done
echo "Expected 401 from $PREVIEW_URL, got $status"
exit 1

- name: Comment the preview URL
uses: actions/github-script@v8
env:
PREVIEW_URL: ${{ steps.config.outputs.url }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
with:
script: |
const marker = '<!-- mcp-preview -->'
const body = [
marker,
`Preview of ${process.env.HEAD_SHA.slice(0, 7)}: \`${process.env.PREVIEW_URL}\``,
'',
'Sign in with a Cloudflare API token:',
'```',
`codex mcp add cf-pr-${context.issue.number} --url ${process.env.PREVIEW_URL} --bearer-token-env-var CF_PREVIEW_TOKEN`,
'```',
'The preview is deleted when this PR closes.'
].join('\n')
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: context.issue.number
})
const existing = comments.find((comment) => comment.body?.startsWith(marker))
if (existing) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
} else {
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
}

teardown:
name: Delete preview
if: github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview')
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v6
with:
node-version: "22"

- name: Delete the preview Worker
run: npx --yes "$WRANGLER" delete --name "cloudflare-api-mcp-pr-$PR_NUMBER" --force
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ node_modules/
dist/
*.log
.worktrees/
.preview/
37 changes: 37 additions & 0 deletions scripts/preview-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { writeFileSync } from 'node:fs'

const [prNumber, domainSuffix, kvId, bucketName] = process.argv.slice(2)
if (!/^\d+$/.test(prNumber ?? '') || !domainSuffix || !kvId || !bucketName) {
throw new Error(
'Usage: node scripts/preview-config.mjs <pr-number> <domain-suffix> <kv-id> <bucket-name>'
)
}

const name = `cloudflare-api-mcp-pr-${prNumber}`
const hostname = `pr-${prNumber}.${domainSuffix}`

const config = {
name,
main: '../src/index.ts',
compatibility_date: '2026-06-17',
compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public'],
workers_dev: false,
preview_urls: false,
observability: { logs: { enabled: true } },
routes: [{ pattern: hostname, custom_domain: true }],
ai: { binding: 'AI' },
worker_loaders: [{ binding: 'LOADER' }],
services: [{ binding: 'GLOBAL_OUTBOUND', service: name, entrypoint: 'GlobalOutbound' }],
kv_namespaces: [{ binding: 'OAUTH_KV', id: kvId }],
r2_buckets: [{ binding: 'SPEC_BUCKET', bucket_name: bucketName }],
analytics_engine_datasets: [{ binding: 'MCP_METRICS', dataset: 'mcp-metrics-preview' }],
vars: {
CLOUDFLARE_API_BASE: 'https://api.cloudflare.com/client/v4',
CLOUDFLARE_OAUTH_DOMAIN: 'https://dash.cloudflare.com',
MCP_RESOURCE: `https://${hostname}/mcp`,
OPENAPI_SPEC_URL: 'https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json'
}
}

writeFileSync('.preview/wrangler.json', JSON.stringify(config, null, 2))
console.log(JSON.stringify({ name, url: config.vars.MCP_RESOURCE }))
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export default {
if (isMcpRoute) {
// Validate Host and browser Origin before authentication so an invalid
// request cannot spend a bearer token on Cloudflare API identity probes.
const rejected = rejectInvalidMcpRequest(request)
const rejected = rejectInvalidMcpRequest(request, env.MCP_RESOURCE)
if (rejected) return rejected
if (request.method === 'OPTIONS') return handleMcpPreflight(request)
}
Expand Down
18 changes: 14 additions & 4 deletions src/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,23 @@ function createAuthenticatedHandler(props: AuthProps, env?: Env, bearer = '') {
// - automatic JSON/SSE response shaping (ordinary requests here remain JSON)

/** Validate the deployment boundary before authentication or MCP dispatch. */
export function rejectInvalidMcpRequest(request: Request): Response | undefined {
export function rejectInvalidMcpRequest(
request: Request,
mcpResource?: string
): Response | undefined {
const deployedHostname = deployedHostnameOf(mcpResource)
return (
hostHeaderValidationResponse(request, ALLOWED_MCP_HOSTNAMES) ??
originValidationResponse(request, ALLOWED_MCP_ORIGIN_HOSTNAMES)
hostHeaderValidationResponse(request, [...ALLOWED_MCP_HOSTNAMES, ...deployedHostname]) ??
originValidationResponse(request, [...ALLOWED_MCP_ORIGIN_HOSTNAMES, ...deployedHostname])
)
}

function deployedHostnameOf(mcpResource: string | undefined): string[] {
if (!mcpResource || !URL.canParse(mcpResource)) return []
const url = new URL(mcpResource)
return url.protocol === 'https:' ? [url.hostname] : []
}

function corsHeaders(request: Request): Headers | undefined {
const origin = request.headers.get('Origin')
if (!origin) return undefined
Expand Down Expand Up @@ -105,7 +115,7 @@ export async function handleAuthenticatedMcpRequest(
return new Response('Not Found', { status: 404 })
}

const rejected = rejectInvalidMcpRequest(request)
const rejected = rejectInvalidMcpRequest(request, env?.MCP_RESOURCE)
if (rejected) return rejected

const props = AuthPropsSchema.parse(rawProps)
Expand Down
37 changes: 37 additions & 0 deletions tests/mcp-deployed-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { rejectInvalidMcpRequest } from '../src/mcp-handler'

function requestFor(host: string, origin?: string): Request {
return new Request(`https://${host}/mcp`, {
method: 'POST',
headers: { Host: host, ...(origin ? { Origin: origin } : {}) }
})
}

describe('deployed MCP hostname', () => {
it('accepts the hostname of the deployment MCP_RESOURCE names', () => {
const host = 'pr-42.mcp-preview.example.dev'
expect(
rejectInvalidMcpRequest(requestFor(host, `https://${host}`), `https://${host}/mcp`)
).toBeUndefined()
})

it('rejects that hostname when MCP_RESOURCE names a different deployment', () => {
const response = rejectInvalidMcpRequest(
requestFor('pr-42.mcp-preview.example.dev'),
'https://mcp.cloudflare.com/mcp'
)
expect(response?.status).toBe(403)
})

it('does not trust a plain-HTTP or malformed MCP_RESOURCE', () => {
expect(
rejectInvalidMcpRequest(requestFor('evil.example'), 'http://evil.example/mcp')?.status
).toBe(403)
expect(rejectInvalidMcpRequest(requestFor('evil.example'), 'not a url')?.status).toBe(403)
})

it('keeps accepting the production hostname without MCP_RESOURCE', () => {
expect(rejectInvalidMcpRequest(requestFor('mcp.cloudflare.com'))).toBeUndefined()
})
})