From 4475054c193b7e28e9ae97a237659739b933d11c Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 5 Mar 2026 18:10:34 +0000 Subject: [PATCH] feat: JIT scope upgrade via ElicitationAgent When the execute tool hits a 403 from the Cloudflare API, instead of returning a generic error, we now create an ElicitationAgent (Durable Object) that serves a scope picker UI matching the Cloudflare design system. The user can upgrade their permissions without a full re-auth: - HTTP status propagation from sandbox worker errors (httpStatus field) - ElicitationAgent renders scope picker with current scopes pre-checked - Scope templates (read-only, workers-full, dns-full) + individual checkboxes - POST /upgrade redirects through Cloudflare OAuth with new scopes - /oauth/callback detects scope_upgrade state, stores upgraded token in KV - Execute handler checks KV for upgraded tokens before each API call - Supports MCP URL elicitation for capable clients, falls back to tool error URL --- package-lock.json | 623 ++++++++++++++++++++++++- package.json | 1 + src/auth/oauth-handler.ts | 50 +- src/auth/types.ts | 3 +- src/elicitation-agent.ts | 915 +++++++++++++++++++++++++++++++++++++ src/executor.ts | 23 +- src/index.ts | 20 +- src/server.ts | 139 +++++- src/tests/jit-auth.test.ts | 151 ++++++ worker-configuration.d.ts | 3 + wrangler.jsonc | 42 ++ 11 files changed, 1954 insertions(+), 16 deletions(-) create mode 100644 src/elicitation-agent.ts create mode 100644 src/tests/jit-auth.test.ts diff --git a/package-lock.json b/package-lock.json index 42eb78f..7ddc389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@cloudflare/workers-oauth-provider": "^0.3.0", "@modelcontextprotocol/sdk": "^1.26.0", + "agents": "^0.7.5", "hono": "^4.12.3", "zod": "^4.3.5" }, @@ -24,6 +25,97 @@ "wrangler": "^4.64.0" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.66", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.66.tgz", + "integrity": "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.19.tgz", + "integrity": "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", + "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.48.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT", + "peer": true + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", @@ -159,6 +251,13 @@ "integrity": "sha512-I39kyUzNQWxVTIQs56xbnnHSrOY5UjiGKVeYI2zY5h+LjUxqeiuOBTTlKEDUirRvnMmBg3yppA6pR8bYKBrTAA==", "license": "MIT" }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260305.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260305.1.tgz", + "integrity": "sha512-835BZaIcgjuYIUqgOWJSpwQxFSJ8g/X1OCZFLO7bmirM6TGmVgIGwiGItBgkjUXXCPrYzJEldsJkuFuK7ePuMw==", + "license": "MIT OR Apache-2.0", + "peer": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1155,6 +1254,12 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -1195,6 +1300,15 @@ } } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oxfmt/binding-android-arm-eabi": { "version": "0.31.0", "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.31.0.tgz", @@ -2240,6 +2354,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2265,6 +2385,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz", @@ -2276,6 +2408,15 @@ "undici-types": "~7.16.0" } }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -2406,6 +2547,103 @@ "node": ">= 0.6" } }, + "node_modules/agents": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/agents/-/agents-0.7.5.tgz", + "integrity": "sha512-sB37uMLt4aenYJwhbCSnIaYZuepoJwXYTX/8WXJGFj/FuWoScHrY+o2LrxKgzSmDA4pMMn4JIXaq/wMHs8OAJg==", + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.1.1", + "@modelcontextprotocol/sdk": "1.26.0", + "cron-schedule": "^6.0.0", + "json-schema": "^0.4.0", + "json-schema-to-typescript": "^15.0.4", + "mimetext": "^3.0.28", + "nanoid": "^5.1.6", + "partyserver": "^0.3.3", + "partysocket": "1.1.16", + "yargs": "^18.0.0" + }, + "bin": { + "agents": "dist/cli/index.js" + }, + "peerDependencies": { + "@ai-sdk/openai": "^3.0.0", + "@ai-sdk/react": "^3.0.0", + "@cloudflare/ai-chat": "^0.1.8", + "@cloudflare/codemode": "^0.1.2", + "@x402/core": "^2.0.0", + "@x402/evm": "^2.0.0", + "ai": "^6.0.0", + "just-bash": "^2.11.0", + "react": "^19.0.0", + "viem": ">=2.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@ai-sdk/openai": { + "optional": true + }, + "@ai-sdk/react": { + "optional": true + }, + "@cloudflare/ai-chat": { + "optional": true + }, + "@cloudflare/codemode": { + "optional": true + }, + "@x402/core": { + "optional": true + }, + "@x402/evm": { + "optional": true + }, + "just-bash": { + "optional": true + }, + "viem": { + "optional": true + } + } + }, + "node_modules/agents/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/ai": { + "version": "6.0.116", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.116.tgz", + "integrity": "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@ai-sdk/gateway": "3.0.66", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -2439,6 +2677,36 @@ } } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2562,6 +2830,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -2602,6 +2884,17 @@ "node": ">=6.6.0" } }, + "node_modules/core-js-pure": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.48.0.tgz", + "integrity": "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2615,6 +2908,15 @@ "node": ">= 0.10" } }, + "node_modules/cron-schedule": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cron-schedule/-/cron-schedule-6.0.0.tgz", + "integrity": "sha512-BoZaseYGXOo5j5HUwTaegIog3JJbuH4BbrY9A1ArLjXpy+RWb3mV28F/9Gv1dDA7E2L8kngWva4NWisnLTyfgQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2695,6 +2997,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -2793,6 +3101,15 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2818,6 +3135,12 @@ "node": ">= 0.6" } }, + "node_modules/event-target-polyfill": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz", + "integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -2937,7 +3260,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3014,6 +3336,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3170,6 +3513,27 @@ "node": ">= 0.10" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3191,6 +3555,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -3198,6 +3568,47 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -3220,6 +3631,12 @@ "node": ">=6" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -3292,6 +3709,43 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimetext": { + "version": "3.0.28", + "resolved": "https://registry.npmjs.org/mimetext/-/mimetext-3.0.28.tgz", + "integrity": "sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@babel/runtime-corejs3": "^7.26.0", + "js-base64": "^3.7.7", + "mime-types": "^2.1.35" + }, + "funding": { + "type": "patreon", + "url": "https://patreon.com/muratgozel" + } + }, + "node_modules/mimetext/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimetext/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/miniflare": { "version": "4.20260210.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260210.0.tgz", @@ -3313,6 +3767,15 @@ "node": ">=18.0.0" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3493,6 +3956,53 @@ "node": ">= 0.8" } }, + "node_modules/partyserver": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/partyserver/-/partyserver-0.3.3.tgz", + "integrity": "sha512-jSGWNZ5lJj9czq+97y9N02HyZZtQH8wmgJRkTQahfGnzgL5+R12dMX3E+p7BscB2cVj6LZLv1uHpKL057rjJmw==", + "license": "ISC", + "dependencies": { + "nanoid": "^5.1.6" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20240729.0" + } + }, + "node_modules/partyserver/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/partysocket": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.1.16.tgz", + "integrity": "sha512-d7xFv+ZC7x0p/DAHWJ5FhxQhimIx+ucyZY+kxL0cKddLBmK9c4p2tEA/L+dOOrWm6EYrRwrBjKQV0uSzOY9x1w==", + "license": "MIT", + "dependencies": { + "event-target-polyfill": "^0.0.4" + }, + "peerDependencies": { + "react": ">=17" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3540,7 +4050,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -3588,6 +4097,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3640,6 +4164,16 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3968,6 +4502,38 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -4012,7 +4578,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -4436,6 +5001,23 @@ "dev": true, "license": "MIT" }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -4464,6 +5046,41 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", diff --git a/package.json b/package.json index abf85d3..9226412 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@cloudflare/workers-oauth-provider": "^0.3.0", "@modelcontextprotocol/sdk": "^1.26.0", + "agents": "^0.7.5", "hono": "^4.12.3", "zod": "^4.3.5" }, diff --git a/src/auth/oauth-handler.ts b/src/auth/oauth-handler.ts index e20512d..deaccb6 100644 --- a/src/auth/oauth-handler.ts +++ b/src/auth/oauth-handler.ts @@ -1,4 +1,5 @@ import { env as cloudflareEnv } from 'cloudflare:workers' +import { getAgentByName } from 'agents' import { Hono } from 'hono' import { z } from 'zod' @@ -164,7 +165,8 @@ export async function handleTokenExchangeCallback( accessToken: z.string(), user: z.object({ id: z.string(), email: z.string() }), accounts: z.array(z.object({ id: z.string(), name: z.string() })), - refreshToken: z.string().optional() + refreshToken: z.string().optional(), + scopes: z.array(z.string()).optional() }) ]) @@ -365,6 +367,49 @@ export function createAuthHandlers() { env.OAUTH_KV ) + // ── Scope upgrade flow ────────────────────────────────────────── + // If the state was created by ElicitationAgent, handle it as a + // scope upgrade instead of a normal OAuth completion. + const upgradeInfo = oauthReqInfo as AuthRequest & { + elicitationId?: string + tokenHash?: string + userId?: string + } + + if (upgradeInfo.responseType === 'scope_upgrade' && upgradeInfo.elicitationId) { + const { access_token, refresh_token } = await getAuthToken({ + client_id: env.CLOUDFLARE_CLIENT_ID, + client_secret: env.CLOUDFLARE_CLIENT_SECRET, + redirect_uri: new URL('/oauth/callback', c.req.url).href, + code, + code_verifier: codeVerifier + }) + + // Notify the ElicitationAgent of the successful upgrade + const stub = await getAgentByName(env.ELICITATION_AGENT, upgradeInfo.elicitationId) + await stub.fetch(new Request('https://agent/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accessToken: access_token, + refreshToken: refresh_token, + scopes: oauthReqInfo.scope + }) + })) + + // Redirect to elicitation success page + const baseUrl = new URL(c.req.url).origin + return new Response(null, { + status: 302, + headers: { + Location: `${baseUrl}/elicitation/${upgradeInfo.elicitationId}`, + 'Set-Cookie': clearCookie + } + }) + } + + // ── Normal OAuth flow ─────────────────────────────────────────── + if (!oauthReqInfo.clientId) { return new OAuthError('invalid_request', 'Invalid OAuth request info').toHtmlResponse() } @@ -407,7 +452,8 @@ export function createAuthHandlers() { user, accounts, accessToken: access_token, - refreshToken: refresh_token + refreshToken: refresh_token, + scopes: oauthReqInfo.scope } satisfies AuthProps }) diff --git a/src/auth/types.ts b/src/auth/types.ts index 8a03c2f..2f308c7 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -23,7 +23,8 @@ export const UserAuthProps = z.object({ accessToken: z.string(), user: UserSchema, accounts: AccountsSchema, - refreshToken: z.string().optional() + refreshToken: z.string().optional(), + scopes: z.array(z.string()).optional() }) export const AuthProps = z.discriminatedUnion('type', [AccountAuthProps, UserAuthProps]) diff --git a/src/elicitation-agent.ts b/src/elicitation-agent.ts new file mode 100644 index 0000000..e42066c --- /dev/null +++ b/src/elicitation-agent.ts @@ -0,0 +1,915 @@ +import { Agent } from 'agents' +import { generatePKCECodes } from './auth/cloudflare-auth' +import { ALL_SCOPES, SCOPE_TEMPLATES, REQUIRED_SCOPES, MAX_SCOPES } from './auth/scopes' +import { createOAuthState, bindStateToSession } from './auth/workers-oauth-utils' +import type { AuthRequest } from '@cloudflare/workers-oauth-provider' + +export type ElicitationState = { + errorMessage: string + currentScopes: string[] + tokenHash: string + userId: string + status: 'pending' | 'upgrading' | 'complete' | 'failed' + createdAt: number + completedAt?: number + newScopes?: string[] + failureReason?: string +} | null + +const HTML_HEADERS: Record = { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': "frame-ancestors 'none'", + 'X-Frame-Options': 'DENY' +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export class ElicitationAgent extends Agent { + initialState: ElicitationState = null + + async onStart() { + if (this.state && this.state.status === 'pending') { + this.schedule(Date.now() + 60 * 60 * 1000, 'cleanup') + } + } + + async onRequest(request: Request): Promise { + const url = new URL(request.url) + // Routes may arrive as /init (internal) or /elicitation/:id (browser). + // Normalize by extracting the last path segment. + const segments = url.pathname.replace(/\/+$/, '').split('/') + const route = segments[segments.length - 1] || '' + + // POST /init — internal call from elicitUpdatedScopes + if (request.method === 'POST' && route === 'init') { + if (this.state !== null) { + return new Response('Conflict: elicitation already initialized', { status: 409 }) + } + const body = (await request.json()) as { + errorMessage: string + currentScopes: string[] + tokenHash: string + userId: string + } + this.setState({ + errorMessage: body.errorMessage, + currentScopes: body.currentScopes, + tokenHash: body.tokenHash, + userId: body.userId, + status: 'pending', + createdAt: Date.now() + }) + this.schedule(Date.now() + 60 * 60 * 1000, 'cleanup') + return new Response('OK', { status: 200 }) + } + + // GET — render scope picker page (matches /elicitation/:id or /) + if (request.method === 'GET' && (route === this.name || route === '' || !['init', 'upgrade', 'complete', 'fail'].includes(route))) { + if (!this.state) return new Response('Not found', { status: 404 }) + + if (this.state.status === 'complete') { + return new Response(renderCompletePage(this.state.newScopes || []), { headers: HTML_HEADERS }) + } + if (this.state.status === 'failed') { + return new Response(renderFailedPage(this.state.failureReason || 'Unknown error'), { headers: HTML_HEADERS }) + } + + return new Response(renderScopePickerPage(this.state, this.name), { headers: HTML_HEADERS }) + } + + // POST /upgrade — user submitted new scopes, redirect to Cloudflare OAuth + if (request.method === 'POST' && route === 'upgrade') { + if (!this.state || this.state.status !== 'pending') { + return new Response('Bad request', { status: 400 }) + } + + const formData = await request.formData() + const selectedScopes = formData.getAll('scopes').filter((s): s is string => typeof s === 'string') + + if (selectedScopes.length === 0) { + return new Response('No scopes selected', { status: 400 }) + } + + // Ensure required scopes are included + const finalScopes = Array.from(new Set([...REQUIRED_SCOPES, ...selectedScopes])).slice(0, MAX_SCOPES) + + this.setState({ ...this.state, status: 'upgrading', newScopes: finalScopes }) + + // Generate PKCE codes + const { codeChallenge, codeVerifier } = await generatePKCECodes() + + // Build a synthetic oauthReqInfo for the state. We mark it as a scope_upgrade + // so /oauth/callback knows to handle it differently. + const oauthReqInfo = { + responseType: 'scope_upgrade', + clientId: 'scope_upgrade', + redirectUri: '', + scope: finalScopes, + state: '', + // Custom fields (preserved by .passthrough() in schema) + elicitationId: this.name, + tokenHash: this.state.tokenHash, + userId: this.state.userId + } as AuthRequest & { elicitationId: string; tokenHash: string; userId: string } + + // Store state in OAUTH_KV + const stateToken = await createOAuthState(oauthReqInfo, this.env.OAUTH_KV, codeVerifier) + + // Bind state to session cookie + const { setCookie: sessionCookie } = await bindStateToSession(stateToken) + + // Build Cloudflare OAuth URL + const stateWithToken = { ...oauthReqInfo, state: stateToken } + const baseUrl = new URL(request.url).origin + const redirectUri = `${baseUrl}/oauth/callback` + + const urlParams = new URLSearchParams({ + response_type: 'code', + client_id: this.env.CLOUDFLARE_CLIENT_ID, + redirect_uri: redirectUri, + state: btoa(JSON.stringify(stateWithToken)), + code_challenge: codeChallenge, + code_challenge_method: 'S256', + scope: finalScopes.join(' ') + }) + + const authUrl = `https://dash.cloudflare.com/oauth2/auth?${urlParams.toString()}` + + return new Response(null, { + status: 302, + headers: { + Location: authUrl, + 'Set-Cookie': sessionCookie + } + }) + } + + // POST /complete — called by /oauth/callback after successful scope upgrade + if (request.method === 'POST' && route === 'complete') { + if (!this.state) { + return new Response('Not found', { status: 404 }) + } + const body = (await request.json()) as { accessToken: string; refreshToken: string; scopes: string[] } + + // Store upgraded token in KV, keyed by the old token hash + await this.env.OAUTH_KV.put( + `token-upgrade:${this.state.tokenHash}`, + JSON.stringify({ + accessToken: body.accessToken, + refreshToken: body.refreshToken, + scopes: body.scopes, + timestamp: Date.now() + }), + { expirationTtl: 3600 } + ) + + this.setState({ + ...this.state, + status: 'complete', + completedAt: Date.now(), + newScopes: body.scopes + }) + + return new Response('OK', { status: 200 }) + } + + // POST /fail — called by /oauth/callback on error + if (request.method === 'POST' && route === 'fail') { + if (!this.state) { + return new Response('Not found', { status: 404 }) + } + const body = (await request.json()) as { reason: string } + this.setState({ ...this.state, status: 'failed', failureReason: body.reason }) + return new Response('OK', { status: 200 }) + } + + return new Response('Method Not Allowed', { status: 405 }) + } + + async cleanup() { + this.setState(null) + } +} + +// ── HTML Rendering ────────────────────────────────────────────────────────── + +function renderScopePickerPage(state: NonNullable, agentId: string): string { + const currentScopes = new Set(state.currentScopes) + + // Build scope groups (same categorization as the /authorize page) + const scopesByCategory: Record> = {} + for (const [scope, desc] of Object.entries(ALL_SCOPES)) { + const parts = scope.split(':') + const category = parts[0].replace(/_/g, ' ') + if (!scopesByCategory[category]) { + scopesByCategory[category] = [] + } + scopesByCategory[category].push({ + scope, + desc, + checked: currentScopes.has(scope) + }) + } + + const scopeGroupsHtml = Object.entries(scopesByCategory) + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([category, scopes]) => ` +
+
${escapeHtml(category)}
+ ${scopes + .map( + ({ scope, desc, checked }) => ` + + ` + ) + .join('')} +
+ ` + ) + .join('') + + // Build template options + const templateOptionsHtml = Object.entries(SCOPE_TEMPLATES) + .map( + ([key, template]) => ` + + ` + ) + .join('') + + return ` + + + + + Upgrade Permissions | Cloudflare MCP + + + + + + +
+ +
+ MCP Server +
+ +
+
+
+

Upgrade Permissions

+

Add scopes to your current session

+
+ +
+
${escapeHtml(state.errorMessage)}
+ +
+
+
Quick Select
+
+ ${templateOptionsHtml} +
+
+ + +
+
+
+ ${scopeGroupsHtml} +
+
+ +
+ + + + + Your current scopes are pre-selected. Add the permissions you need, then click Continue to authorize via Cloudflare. +
+ +
+ + +
+
+
+
+
+ + + + + +` +} + +function renderCompletePage(newScopes: string[]): string { + return ` + + + + + Permissions Upgraded | Cloudflare MCP + + + + +
+ +
+ MCP Server +
+
+
+
+ + + +
+

Permissions Upgraded

+

Your token has been upgraded with ${newScopes.length} scopes. You can close this window and retry the operation.

+ Close Window +
+
+ + +` +} + +function renderFailedPage(reason: string): string { + return ` + + + + + Upgrade Failed | Cloudflare MCP + + + + +
+ +
+ MCP Server +
+
+
+
+ + + + + +
+

Upgrade Failed

+

The permission upgrade could not be completed.

+
${escapeHtml(reason)}
+ Close Window +
+
+ + +` +} diff --git a/src/executor.ts b/src/executor.ts index 892ab33..b3499cd 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -1,5 +1,5 @@ interface CodeExecutorEntrypoint { - evaluate(): Promise<{ result: unknown; err?: string; stack?: string }> + evaluate(): Promise<{ result: unknown; err?: string; stack?: string; httpStatus?: number }> } interface SearchExecutorEntrypoint { @@ -65,7 +65,9 @@ export default class CodeExecutor extends WorkerEntrypoint { if (!responseContentType.includes("application/json")) { const text = await response.text(); if (!response.ok) { - throw new Error("Cloudflare API error: " + response.status + " " + text); + const err = new Error("Cloudflare API error: " + response.status + " " + text); + err.httpStatus = response.status; + throw err; } return { success: true, status: response.status, result: text }; } @@ -83,7 +85,9 @@ export default class CodeExecutor extends WorkerEntrypoint { // Complete failure: no data, only errors if (graphqlErrors.length > 0 && !hasData) { const msgs = graphqlErrors.map(e => e.message).join(", "); - throw new Error("GraphQL error: " + msgs); + const err = new Error("GraphQL error: " + msgs); + err.httpStatus = response.status; + throw err; } // Success or partial success @@ -105,7 +109,9 @@ export default class CodeExecutor extends WorkerEntrypoint { // Handle REST API responses if (!data.success) { const errors = data.errors.map(e => e.code + ": " + e.message).join(", "); - throw new Error("Cloudflare API error: " + errors); + const err = new Error("Cloudflare API error: " + errors); + err.httpStatus = response.status; + throw err; } return { ...data, status: response.status }; @@ -116,7 +122,8 @@ export default class CodeExecutor extends WorkerEntrypoint { const result = await (${code})(); return { result, err: undefined }; } catch (err) { - return { result: undefined, err: err.message, stack: err.stack }; + const httpStatus = (typeof err === 'object' && err !== null) ? err.httpStatus : undefined; + return { result: undefined, err: err.message, stack: err.stack, httpStatus }; } } } @@ -128,7 +135,11 @@ export default class CodeExecutor extends WorkerEntrypoint { const response = await entrypoint.evaluate() if (response.err) { - throw new Error(response.err) + const error = new Error(response.err) + if (response.httpStatus !== undefined) { + ;(error as any).httpStatus = response.httpStatus + } + throw error } return response.result diff --git a/src/index.ts b/src/index.ts index f08d5c3..c6a0890 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import OAuthProvider from '@cloudflare/workers-oauth-provider' import { Hono } from 'hono' import { WorkerEntrypoint } from 'cloudflare:workers' +import { getAgentByName } from 'agents' import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' import { createServer } from './server' import { createAuthHandlers, handleTokenExchangeCallback } from './auth/oauth-handler' @@ -8,6 +9,8 @@ import { isDirectApiToken, handleApiTokenRequest } from './auth/api-token-mode' import { processSpec, extractProducts } from './spec-processor' import type { AuthProps } from './auth/types' +export { ElicitationAgent } from './elicitation-agent' + /** * Global outbound fetch handler that restricts dynamically-loaded workers * to only make requests to the configured Cloudflare API base URL. @@ -48,7 +51,8 @@ async function createMcpResponse( accountId?: string, props?: AuthProps ): Promise { - const server = await createServer(env, ctx, token, accountId, props) + const baseUrl = new URL(request.url).origin + const server = await createServer(env, ctx, token, accountId, props, baseUrl) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, @@ -87,6 +91,20 @@ function createMcpHandler() { export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + // Route /elicitation/:id[/action] to ElicitationAgent DO (browser callback, no auth needed) + const url = new URL(request.url) + if (url.pathname.startsWith('/elicitation/')) { + const rest = url.pathname.slice('/elicitation/'.length) + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i + const match = rest.match(uuidRegex) + if (!match) { + return new Response('Bad Request: invalid elicitation ID', { status: 400 }) + } + const id = match[0] + const stub = await getAgentByName(env.ELICITATION_AGENT, id) + return stub.fetch(request) + } + // Check for direct API token first (like GitHub MCP's PAT support) if (isDirectApiToken(request)) { const response = await handleApiTokenRequest(request, (token, accountId, props) => diff --git a/src/server.ts b/src/server.ts index 3513bc6..03a3e1d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { UrlElicitationRequiredError } from '@modelcontextprotocol/sdk/types.js' +import { getAgentByName } from 'agents' import { z } from 'zod' import { createCodeExecutor, createSearchExecutor } from './executor' import { truncateResponse } from './truncate' @@ -40,6 +42,115 @@ function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error) } +const SCOPES_DOCS_URL = 'https://developers.cloudflare.com/fundamentals/api/reference/permissions/' + +const ELICITATION_MESSAGE = + 'This API call requires additional permissions. ' + + 'Open the link below to upgrade your scopes, then retry the operation.' + +type ToolResult = { content: { type: 'text'; text: string }[]; isError: true } + +/** + * Compute a SHA-256 hash of the API token for use as a KV key. + * This avoids storing the raw token in agent state. + */ +async function hashToken(token: string): Promise { + const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)) + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +/** + * Check KV for a scope-upgraded token. Returns the upgraded token if available. + */ +async function getUpgradedToken( + env: Env, + tokenHash: string +): Promise { + const data = await env.OAUTH_KV.get(`token-upgrade:${tokenHash}`, 'json') as { + accessToken: string + refreshToken: string + scopes: string[] + timestamp: number + } | null + + return data?.accessToken ?? null +} + +interface ElicitationContext { + server: McpServer + env: Env + baseUrl: string + currentScopes?: string[] + tokenHash: string + userId?: string +} + +/** + * Handle 403 (insufficient scope) errors from the Cloudflare API. + * + * Creates an ElicitationAgent with a scope picker UI so the user can + * upgrade their permissions without a full re-auth. + * + * - If the client supports URL elicitation: throws UrlElicitationRequiredError + * - If the client does NOT support elicitation: returns a tool error result + * containing the scope upgrade URL + * - If not a 403: returns undefined (caller should handle normally) + */ +export async function elicitUpdatedScopes( + error: unknown, + ctx?: ElicitationContext +): Promise { + if (!(error instanceof Error)) return undefined + const status = (error as any).httpStatus + if (status !== 403) return undefined + + const elicitationId = crypto.randomUUID() + let callbackUrl = SCOPES_DOCS_URL + + // Create an ElicitationAgent with scope picker if the binding is available + if (ctx?.env.ELICITATION_AGENT) { + try { + const stub = await getAgentByName(ctx.env.ELICITATION_AGENT, elicitationId) + await stub.fetch(new Request('https://agent/init', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + errorMessage: error.message, + currentScopes: ctx.currentScopes || [], + tokenHash: ctx.tokenHash, + userId: ctx.userId || 'unknown' + }) + })) + callbackUrl = `${ctx.baseUrl}/elicitation/${elicitationId}` + } catch { + // Fall back to static docs URL if agent creation fails + } + } + + const message = `${ELICITATION_MESSAGE}\n\n(${error.message})` + + // Check if the client supports URL elicitation + const capabilities = ctx?.server.server.getClientCapabilities() + if (capabilities?.elicitation?.url) { + throw new UrlElicitationRequiredError([ + { + mode: 'url', + message, + url: callbackUrl, + elicitationId + } + ]) + } + + // Client does NOT support elicitation — return URL in tool error result + return { + content: [{ type: 'text', text: `${message}\n\nUpgrade permissions here: ${callbackUrl}` }], + isError: true + } +} + const SPEC_TYPES = ` interface OperationInfo { summary?: string; @@ -68,7 +179,8 @@ export async function createServer( ctx: ExecutionContext, apiToken: string, accountId: string | undefined, - props?: AuthProps + props?: AuthProps, + baseUrl?: string ): Promise { const server = new McpServer({ name: 'cloudflare-api', @@ -78,6 +190,13 @@ export async function createServer( const executeCode = createCodeExecutor(env, ctx) const executeSearch = createSearchExecutor(env) + // Pre-compute token hash for scope upgrade lookups + const tokenHash = await hashToken(apiToken) + + // Extract user context for elicitation + const currentScopes = props?.type === 'user_token' ? props.scopes : undefined + const userId = props?.type === 'user_token' ? props.user.id : undefined + const obj = await env.SPEC_BUCKET.get('products.json') const products: string[] = obj ? await obj.json() : [] @@ -162,9 +281,16 @@ async () => { }, async ({ code }) => { try { - const result = await executeCode(code, accountId, apiToken) + // Check for scope-upgraded token + const effectiveToken = await getUpgradedToken(env, tokenHash) || apiToken + const result = await executeCode(code, accountId, effectiveToken) return { content: [{ type: 'text', text: truncateResponse(result) }] } } catch (error) { + const elicitation = await elicitUpdatedScopes( + error, + baseUrl ? { server, env, baseUrl, currentScopes, tokenHash, userId } : undefined + ) + if (elicitation) return elicitation return { content: [{ type: 'text', text: `Error: ${formatError(error)}` }], isError: true @@ -219,9 +345,16 @@ async () => { } } - const result = await executeCode(code, effectiveAccountId, apiToken) + // Check for scope-upgraded token + const effectiveToken = await getUpgradedToken(env, tokenHash) || apiToken + const result = await executeCode(code, effectiveAccountId, effectiveToken) return { content: [{ type: 'text', text: truncateResponse(result) }] } } catch (error) { + const elicitation = await elicitUpdatedScopes( + error, + baseUrl ? { server, env, baseUrl, currentScopes, tokenHash, userId } : undefined + ) + if (elicitation) return elicitation return { content: [{ type: 'text', text: `Error: ${formatError(error)}` }], isError: true diff --git a/src/tests/jit-auth.test.ts b/src/tests/jit-auth.test.ts new file mode 100644 index 0000000..4ac6fce --- /dev/null +++ b/src/tests/jit-auth.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createCodeExecutor } from '../executor' +import { elicitUpdatedScopes } from '../server' + +describe('JIT Auth - HTTP Status Propagation', () => { + let mockEnv: Env + let mockCtx: any + let mockWorker: any + let mockEntrypoint: any + + beforeEach(() => { + mockEntrypoint = { + evaluate: vi.fn() + } + + mockWorker = { + getEntrypoint: vi.fn(() => mockEntrypoint) + } + + mockEnv = { + CLOUDFLARE_API_BASE: 'https://api.cloudflare.com/client/v4', + LOADER: { + get: vi.fn(() => mockWorker) + } + } as any + + mockCtx = { + exports: { + GlobalOutbound: vi.fn(() => ({ fetch: vi.fn() })) + } + } as any + }) + + describe('Executor httpStatus propagation', () => { + it('should include httpStatus in worker code error handling', async () => { + mockEntrypoint.evaluate.mockResolvedValue({ result: {}, err: undefined }) + const executor = createCodeExecutor(mockEnv, mockCtx) + await executor('async () => { return {} }', 'test-account', 'test-token') + + const loaderCall = mockEnv.LOADER.get as any + const workerConfig = loaderCall.mock.calls[0][1]() + const workerCode = workerConfig.modules['worker.js'] + + expect(workerCode).toContain('err.httpStatus = response.status') + expect(workerCode).toContain('err.httpStatus') + }) + + it('should propagate httpStatus 403 from sandbox response to thrown error', async () => { + mockEntrypoint.evaluate.mockResolvedValue({ + result: undefined, + err: 'Cloudflare API error: 10000: Authentication error', + stack: 'Error: ...', + httpStatus: 403 + }) + + const executor = createCodeExecutor(mockEnv, mockCtx) + + try { + await executor('async () => {}', 'test-account', 'test-token') + expect.unreachable('should have thrown') + } catch (error: any) { + expect(error).toBeInstanceOf(Error) + expect(error.message).toBe('Cloudflare API error: 10000: Authentication error') + expect(error.httpStatus).toBe(403) + } + }) + + it('should propagate httpStatus 500 from sandbox', async () => { + mockEntrypoint.evaluate.mockResolvedValue({ + result: undefined, + err: 'Cloudflare API error: Internal Server Error', + stack: 'Error: ...', + httpStatus: 500 + }) + + const executor = createCodeExecutor(mockEnv, mockCtx) + + try { + await executor('async () => {}', 'test-account', 'test-token') + expect.unreachable('should have thrown') + } catch (error: any) { + expect(error).toBeInstanceOf(Error) + expect(error.httpStatus).toBe(500) + } + }) + + it('should not set httpStatus when sandbox error has no status', async () => { + mockEntrypoint.evaluate.mockResolvedValue({ + result: undefined, + err: 'User code threw an error', + stack: 'Error: ...' + }) + + const executor = createCodeExecutor(mockEnv, mockCtx) + + try { + await executor('async () => {}', 'test-account', 'test-token') + expect.unreachable('should have thrown') + } catch (error: any) { + expect(error).toBeInstanceOf(Error) + expect(error.message).toBe('User code threw an error') + expect(error.httpStatus).toBeUndefined() + } + }) + }) +}) + +describe('JIT Auth - elicitUpdatedScopes', () => { + it('should return tool error with URL for 403 errors (no ctx)', async () => { + const error = new Error('Cloudflare API error: 10000: Authentication error') + ;(error as any).httpStatus = 403 + + const result = await elicitUpdatedScopes(error) + expect(result).toBeDefined() + expect(result!.isError).toBe(true) + expect(result!.content[0].text).toContain('10000: Authentication error') + expect(result!.content[0].text).toContain( + 'https://developers.cloudflare.com/fundamentals/api/reference/permissions/' + ) + }) + + it('should include re-authorize URL in the tool error message', async () => { + const error = new Error('Cloudflare API error: 10000: Authentication error') + ;(error as any).httpStatus = 403 + + const result = await elicitUpdatedScopes(error) + expect(result!.content[0].text).toContain('Upgrade permissions here:') + }) + + it('should return undefined for non-403 errors', async () => { + const error = new Error('Cloudflare API error: Internal Server Error') + ;(error as any).httpStatus = 500 + + const result = await elicitUpdatedScopes(error) + expect(result).toBeUndefined() + }) + + it('should return undefined for errors without httpStatus', async () => { + const error = new Error('some user error') + + const result = await elicitUpdatedScopes(error) + expect(result).toBeUndefined() + }) + + it('should return undefined for non-Error values', async () => { + expect(await elicitUpdatedScopes('some string error')).toBeUndefined() + expect(await elicitUpdatedScopes(42)).toBeUndefined() + expect(await elicitUpdatedScopes(null)).toBeUndefined() + expect(await elicitUpdatedScopes(undefined)).toBeUndefined() + }) +}) diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 30ad334..7ffd064 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -9,6 +9,7 @@ declare namespace Cloudflare { OAUTH_KV: KVNamespace; SPEC_BUCKET: R2Bucket; LOADER: WorkerLoader; + ELICITATION_AGENT: DurableObjectNamespace; CLOUDFLARE_API_BASE: "https://api.cloudflare.com/client/v4"; OPENAPI_SPEC_URL: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json"; MCP_COOKIE_ENCRYPTION_KEY: string; @@ -21,6 +22,7 @@ declare namespace Cloudflare { OAUTH_KV: KVNamespace; SPEC_BUCKET: R2Bucket; LOADER: WorkerLoader; + ELICITATION_AGENT: DurableObjectNamespace; CLOUDFLARE_API_BASE: "https://api.cloudflare.com/client/v4"; OPENAPI_SPEC_URL: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json"; MCP_COOKIE_ENCRYPTION_KEY: string; @@ -37,6 +39,7 @@ declare namespace Cloudflare { OAUTH_KV: KVNamespace; SPEC_BUCKET: R2Bucket; LOADER: WorkerLoader; + ELICITATION_AGENT: DurableObjectNamespace; CLOUDFLARE_API_BASE: "https://api.cloudflare.com/client/v4"; OPENAPI_SPEC_URL: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json"; GLOBAL_OUTBOUND: Service /* entrypoint GlobalOutbound from cloudflare-api-mcp-staging */ | Service /* entrypoint GlobalOutbound from cloudflare-api-mcp */ | Service; diff --git a/wrangler.jsonc b/wrangler.jsonc index 2970be2..556b8b5 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -42,6 +42,20 @@ "bucket_name": "mcp-spec" } ], + "durable_objects": { + "bindings": [ + { + "name": "ELICITATION_AGENT", + "class_name": "ElicitationAgent" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ElicitationAgent"] + } + ], "triggers": { "crons": ["0 0 * * *"] }, @@ -81,6 +95,20 @@ "bucket_name": "mcp-spec-staging" } ], + "durable_objects": { + "bindings": [ + { + "name": "ELICITATION_AGENT", + "class_name": "ElicitationAgent" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ElicitationAgent"] + } + ], "triggers": { "crons": ["0 0 * * *"] }, @@ -121,6 +149,20 @@ "bucket_name": "mcp-spec-production" } ], + "durable_objects": { + "bindings": [ + { + "name": "ELICITATION_AGENT", + "class_name": "ElicitationAgent" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ElicitationAgent"] + } + ], "triggers": { "crons": ["0 0 * * *"] },