From 58984d4d0ce8405329f81bdc5b8e3df878080bc5 Mon Sep 17 00:00:00 2001 From: Jordan Coeyman Date: Wed, 14 Oct 2026 10:43:18 -0400 Subject: [PATCH 1/3] feat(ci): deploy a preview of each labelled PR A maintainer adds the preview label to deploy the PR head to pr-. on a preview account. The workflow checks the server answers with 401, comments the URL on the PR, and deletes the Worker when the PR closes. New commits remove the label, so each update needs a fresh review. The MCP host allowlist also accepts the HTTPS hostname in MCP_RESOURCE, so a deployment trusts only its own hostname. --- .github/workflows/preview.yml | 124 ++++++++++++++++++++++++++++++++ .gitignore | 1 + scripts/preview-config.mjs | 37 ++++++++++ src/index.ts | 2 +- src/mcp-handler.ts | 18 +++-- tests/mcp-deployed-host.test.ts | 37 ++++++++++ 6 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/preview.yml create mode 100644 scripts/preview-config.mjs create mode 100644 tests/mcp-deployed-host.test.ts diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..fc893b49 --- /dev/null +++ b/.github/workflows/preview.yml @@ -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 attempt in 1 2 3 4 5 6; 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"}}}') + [ "$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 = '' + 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 diff --git a/.gitignore b/.gitignore index 2e00c039..5c995de5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ dist/ *.log .worktrees/ +.preview/ diff --git a/scripts/preview-config.mjs b/scripts/preview-config.mjs new file mode 100644 index 00000000..0cc47bf8 --- /dev/null +++ b/scripts/preview-config.mjs @@ -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 ' + ) +} + +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 })) diff --git a/src/index.ts b/src/index.ts index 4d895ac0..5063e713 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) } diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts index f5a6a21a..c92ba648 100644 --- a/src/mcp-handler.ts +++ b/src/mcp-handler.ts @@ -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 @@ -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) diff --git a/tests/mcp-deployed-host.test.ts b/tests/mcp-deployed-host.test.ts new file mode 100644 index 00000000..a8ca6529 --- /dev/null +++ b/tests/mcp-deployed-host.test.ts @@ -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() + }) +}) From b1b5fdfa689ae8b506650beb81a3c5e13d679287 Mon Sep 17 00:00:00 2001 From: Jordan Coeyman Date: Wed, 14 Oct 2026 10:44:10 -0400 Subject: [PATCH 2/3] ci(preview): satisfy shellcheck --- .github/workflows/preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index fc893b49..d7850006 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -72,7 +72,7 @@ jobs: env: PREVIEW_URL: ${{ steps.config.outputs.url }} run: | - for attempt in 1 2 3 4 5 6; do + for _ in 1 2 3 4 5 6; 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"}}}') From f683703f3a069ff8536b8f609b1b6556ed5e891b Mon Sep 17 00:00:00 2001 From: Jordan Coeyman Date: Wed, 14 Oct 2026 10:49:50 -0400 Subject: [PATCH 3/3] ci(preview): keep retrying while the new hostname starts resolving --- .github/workflows/preview.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index d7850006..f1168a60 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -72,10 +72,10 @@ jobs: env: PREVIEW_URL: ${{ steps.config.outputs.url }} run: | - for _ in 1 2 3 4 5 6; do + 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"}}}') + -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