diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..f1168a60 --- /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 _ 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 = '' + 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() + }) +})