diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..369f4d19 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Developing + +We welcome contributions to all of our MCP servers! Here's a quick run down on how to get started. + +## Architecture + +This monorepo has two top-level directories: `/apps` and `/packages`. + +- **/apps**: Containing directories for each server. Within each server, you'll find a `CONTRIBUTING.md` with any special instructions on how to get set up: + - [apps/workers-observability](apps/workers-observability) + - [apps/workers-bindings](apps/workers-bindings) + - [apps/radar](apps/radar) + - [apps/cloudflare-one-casb](apps/cloudflare-one-casb) +- **/packages**: Containing shared packages used across our various apps. + - packages/eslint-config: Eslint config used by all apps and packages. + - packages/typescript-config: tsconfig used by all apps and packages. + - packages/mcp-common: Shared common tools and scripts to help manage this repo. + +We use [TurboRepo](https://turbo.build/) and [pnpm](https://pnpm.io/) to manage this repository. TurboRepo manages the monorepo by ensuring commands are run across all apps. + +## Getting Started + +This section will guide you through setting up your developer environment and running tests. + +### Installation + +Install dependencies: + +```bash +pnpm install +``` + +### Testing + +The project uses Vitest as the testing framework with [fetchMock](https://developers.cloudflare.com/workers/testing/vitest-integration/test-apis/) for API mocking. + +#### Running Tests + +To run all tests: + +```bash +pnpm test +``` + +To run a specific test file: + +```bash +pnpm test -- tests/tools/queues.test.ts +``` + +To run tests in watch mode (useful during development): + +```bash +pnpm test:watch +``` diff --git a/README.md b/README.md index 5c62f3c2..c0fd762d 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,34 @@ # Cloudflare MCP Server -Model Context Protocol (MCP) is a [new, standardized protocol](https://modelcontextprotocol.io/introduction) for managing context between large language models (LLMs) and external systems. In this repository, we provide an installer as well as an MCP Server for [Cloudflare's API](https://api.cloudflare.com). +Model Context Protocol (MCP) is a [new, standardized protocol](https://modelcontextprotocol.io/introduction) for managing context between large language models (LLMs) and external systems. In this repository, you can find several MCP servers allowing you to connect to Cloudflare's service from an MCP client (e.g. Cursor, Claude) and use natural language to accomplish tasks through your Cloudflare account. -This lets you use Claude Desktop, or any MCP Client, to use natural language to accomplish things on your Cloudflare account, e.g.: +These MCP servers allow your [MCP Client](https://modelcontextprotocol.io/clients) to read configurations from your account, process information, make suggestions based on data, and even make those suggested changes for you. All of these actions can happen across cloudflare's many services including application development, security and performance. -- `List all the Cloudflare workers on my @gmail.com account.` -- `Can you tell me about any potential issues on this particular worker '...'?` +The following servers are included in this repository: -## Access the remote MCP server from Claude Desktop +| Server Name | Description | Server URL | +| ----------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------- | +| [**Documentation server**](/apps/docs-autorag) | Get up to date reference information on Cloudflare | `https://docs.mcp.cloudflare.com/sse` | +| [**Workers Bindings server**](/apps/bindings) | Build Workers applications with storage, AI, and compute primitives | `https://bindings.mcp.cloudflare.com/sse` | +| [**Observability server**](/apps/observability) | Debug and get insight into your application’s logs and analytics | `https://observability.mcp.cloudflare.com/sse` | +| [**Radar server**](/apps/radar) | Get global Internet traffic insights, trends, URL scans, and other utilities | `https://radar.mcp.cloudflare.com/sse` | -Open Claude Desktop and navigate to Settings -> Developer -> Edit Config. This opens the configuration file that controls which MCP servers Claude can access. +## Access the remote MCP server from any MCP client -Replace the content with the following configuration. Once you restart Claude Desktop, a browser window will open showing your OAuth login page. Complete the authentication flow to grant Claude access to your MCP server. After you grant access, the tools will become available for you to use. +If your MCP client has first class support for remote MCP servers, the client will provide a way to accept the server URL directly within its interface (e.g. [Cloudflare AI Playground](https://playground.ai.cloudflare.com/)) + +If your client does not yet support remote MCP servers, you will need to set up its resepective configuration file using mcp-remote (https://www.npmjs.com/package/mcp-remote) to specify which servers your client can access. ```json { "mcpServers": { - "cloudflare": { + "cloudflare-observability": { "command": "npx", "args": ["mcp-remote", "https://observability.mcp.cloudflare.com/sse"] + }, + "cloudflare-bindings": { + "command": "npx", + "args": ["mcp-remote", "https://bindings.mcp.cloudflare.com/sse"] } } } @@ -26,66 +36,12 @@ Replace the content with the following configuration. Once you restart Claude De ## Need access to more Cloudflare tools? -We're gradually moving over functionality to this remote MCP server repo. In the meantime please take a look at the local only mcp-server-cloudflare package which currently has more tools available. - -Visit +We're continuing to add more functionality to this remote MCP server repo. If you'd like to leave feedback, file a bug or provide a feature request, [please open an issue](https://github.com/cloudflare/mcp-server-cloudflare/issues/new/choose) on this repository ## Paid Features Some features may require a paid Cloudflare Workers plan. Ensure your Cloudflare account has the necessary subscription level for the features you intend to use. -## Features - -### Workers Management - -- `worker_list`: List all Workers in your account -- `worker_get_worker`: Get a Worker's script content - -### Workers Logs - -- `worker_logs_by_worker_name`: Analyze recent logs for a Cloudflare Worker by worker name -- `worker_logs_by_ray_id`: Analyze recent logs across all workers for a specific request by Cloudflare Ray ID -- `worker_logs_keys`: Get available telemetry keys for a Cloudflare Worker - -## Developing - -### Apps - -- [workers-observability](apps/workers-observability): The Workers Observability MCP server -- [radar](apps/radar): The Cloudflare Radar MCP server - -### Packages - -- eslint-config: Eslint config used by all apps and packages. -- typescript-config: tsconfig used by all apps and packages. -- mcp-common: Shared common tools and scripts to help manage this repo. - -For more details on development in this monorepo, take a look at apps/workers-observability - -## Testing - -The project uses Vitest as the testing framework with MSW (Mock Service Worker) for API mocking. - -### Running Tests - -To run all tests: - -```bash -pnpm test -``` - -To run a specific test file: - -```bash -pnpm test -- tests/tools/queues.test.ts -``` - -To run tests in watch mode (useful during development): - -```bash -pnpm test:watch -``` - ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Interested in contributing, and running this server locally? See [CONTRIBUTING.md](CONTRIBUTING.md) to get started. diff --git a/apps/cloudflare-one-casb/package.json b/apps/cloudflare-one-casb/package.json index 60b20e36..42589b84 100644 --- a/apps/cloudflare-one-casb/package.json +++ b/apps/cloudflare-one-casb/package.json @@ -23,7 +23,6 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.8.14", - "@cloudflare/workers-types": "4.20250410.0", "@types/jsonwebtoken": "9.0.9", "prettier": "3.5.3", "typescript": "5.5.4", diff --git a/apps/cloudflare-one-casb/src/context.ts b/apps/cloudflare-one-casb/src/context.ts index 4789afe4..b8bda3f0 100644 --- a/apps/cloudflare-one-casb/src/context.ts +++ b/apps/cloudflare-one-casb/src/context.ts @@ -7,9 +7,10 @@ export interface Env { MCP_OBJECT: DurableObjectNamespace MCP_METRICS: AnalyticsEngineDataset AI: Ai - CLOUDFLARE_CLIENT_ID: string CLOUDFLARE_CLIENT_SECRET: string - USER_DETAILS: DurableObjectNamespace + DEV_DISABLE_OAUTH: string + DEV_CLOUDFLARE_API_TOKEN: string + DEV_CLOUDFLARE_EMAIL: string } diff --git a/apps/cloudflare-one-casb/src/index.ts b/apps/cloudflare-one-casb/src/index.ts index 26f0d323..550af986 100644 --- a/apps/cloudflare-one-casb/src/index.ts +++ b/apps/cloudflare-one-casb/src/index.ts @@ -7,6 +7,7 @@ import { } from '@repo/mcp-common/src/cloudflare-oauth-handler' import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details' import { getEnv } from '@repo/mcp-common/src/env' +import { RequiredScopes } from '@repo/mcp-common/src/scopes' import { CloudflareMCPServer } from '@repo/mcp-common/src/server' import { registerAccountTools } from '@repo/mcp-common/src/tools/account' @@ -86,10 +87,9 @@ export class CASBMCP extends McpAgent { } } const CloudflareOneCasbScopes = { + ...RequiredScopes, 'account:read': 'See your account info such as account details, analytics, and memberships.', - 'user:read': 'See your user info such as name, email address, and account memberships.', 'teams:read': 'See Cloudflare One Resources', - offline_access: 'Grants refresh tokens for long-lived access.', } as const export default new OAuthProvider({ diff --git a/apps/dex-analysis/.eslintrc.cjs b/apps/dex-analysis/.eslintrc.cjs index d79f80e8..f6bf291a 100644 --- a/apps/dex-analysis/.eslintrc.cjs +++ b/apps/dex-analysis/.eslintrc.cjs @@ -2,4 +2,4 @@ module.exports = { root: true, extends: ['@repo/eslint-config/default.cjs'], -} \ No newline at end of file +} diff --git a/apps/dex-analysis/package.json b/apps/dex-analysis/package.json index 185326d7..9b1bc074 100644 --- a/apps/dex-analysis/package.json +++ b/apps/dex-analysis/package.json @@ -25,11 +25,10 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.8.14", - "@cloudflare/workers-types": "4.20250410.0", "@types/jsonwebtoken": "9.0.9", "prettier": "3.5.3", "typescript": "5.5.4", "vitest": "3.0.9", "wrangler": "4.10.0" } -} \ No newline at end of file +} diff --git a/apps/dex-analysis/src/context.ts b/apps/dex-analysis/src/context.ts index 757e4f24..b2fbe2a7 100644 --- a/apps/dex-analysis/src/context.ts +++ b/apps/dex-analysis/src/context.ts @@ -11,4 +11,7 @@ export interface Env { MCP_OBJECT: DurableObjectNamespace USER_DETAILS: DurableObjectNamespace MCP_METRICS: AnalyticsEngineDataset + DEV_DISABLE_OAUTH: string + DEV_CLOUDFLARE_API_TOKEN: string + DEV_CLOUDFLARE_EMAIL: string } diff --git a/apps/dex-analysis/src/index.ts b/apps/dex-analysis/src/index.ts index a9166751..ebb71e4c 100644 --- a/apps/dex-analysis/src/index.ts +++ b/apps/dex-analysis/src/index.ts @@ -3,6 +3,7 @@ import { McpAgent } from 'agents/mcp' import { createAuthHandlers, + getUserAndAccounts, handleTokenExchangeCallback, } from '@repo/mcp-common/src/cloudflare-oauth-handler' import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details' @@ -96,16 +97,42 @@ const DexScopes = { 'dex:read': 'See Cloudflare Cloudflare DEX data for your account', } as const -export default new OAuthProvider({ - apiRoute: '/sse', - apiHandler: CloudflareDEXMCP.mount('/sse'), - // @ts-ignore - defaultHandler: createAuthHandlers({ scopes: DexScopes, metrics }), - authorizeEndpoint: '/oauth/authorize', - tokenEndpoint: '/token', - tokenExchangeCallback: (options) => - handleTokenExchangeCallback(options, env.CLOUDFLARE_CLIENT_ID, env.CLOUDFLARE_CLIENT_SECRET), - // Cloudflare access token TTL - accessTokenTTL: 3600, - clientRegistrationEndpoint: '/register', -}) +// TODO: Move this in to mcp-common +async function handleDevMode(req: Request, env: Env, ctx: ExecutionContext) { + const { user, accounts } = await getUserAndAccounts(env.DEV_CLOUDFLARE_API_TOKEN, { + 'X-Auth-Email': env.DEV_CLOUDFLARE_EMAIL, + 'X-Auth-Key': env.DEV_CLOUDFLARE_API_TOKEN, + }) + ctx.props = { + accessToken: env.DEV_CLOUDFLARE_API_TOKEN, + user, + accounts, + } as Props + return CloudflareDEXMCP.mount('/sse').fetch(req, env, ctx) +} + +export default { + fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { + if (env.ENVIRONMENT === 'development' && env.DEV_DISABLE_OAUTH === 'true') { + return await handleDevMode(req, env, ctx) + } + + return new OAuthProvider({ + apiRoute: '/sse', + apiHandler: CloudflareDEXMCP.mount('/sse'), + // @ts-ignore + defaultHandler: createAuthHandlers({ scopes: DexScopes, metrics }), + authorizeEndpoint: '/oauth/authorize', + tokenEndpoint: '/token', + tokenExchangeCallback: (options) => + handleTokenExchangeCallback( + options, + env.CLOUDFLARE_CLIENT_ID, + env.CLOUDFLARE_CLIENT_SECRET + ), + // Cloudflare access token TTL + accessTokenTTL: 3600, + clientRegistrationEndpoint: '/register', + }).fetch(req, env, ctx) + }, +} diff --git a/apps/dex-analysis/tsconfig.json b/apps/dex-analysis/tsconfig.json index 8682b718..9dcfd4e8 100644 --- a/apps/dex-analysis/tsconfig.json +++ b/apps/dex-analysis/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@repo/typescript-config/workers.json", - "include": ["*/**.ts"] + "include": ["*/**.ts", "./vitest.config.ts", "./types.d.ts"] } diff --git a/apps/dex-analysis/types.d.ts b/apps/dex-analysis/types.d.ts index 70a597d9..e090f9a7 100644 --- a/apps/dex-analysis/types.d.ts +++ b/apps/dex-analysis/types.d.ts @@ -2,4 +2,4 @@ import type { TestEnv } from './vitest.config' declare module 'cloudflare:test' { interface ProvidedEnv extends TestEnv {} -} \ No newline at end of file +} diff --git a/apps/dex-analysis/vitest.config.ts b/apps/dex-analysis/vitest.config.ts index 814b308e..c201c144 100644 --- a/apps/dex-analysis/vitest.config.ts +++ b/apps/dex-analysis/vitest.config.ts @@ -1,5 +1,7 @@ import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config' +import type { Env } from './src/context' + export interface TestEnv extends Env { CLOUDFLARE_MOCK_ACCOUNT_ID: string CLOUDFLARE_MOCK_API_TOKEN: string @@ -19,4 +21,4 @@ export default defineWorkersConfig({ }, }, }, -}) \ No newline at end of file +}) diff --git a/apps/dex-analysis/wrangler.jsonc b/apps/dex-analysis/wrangler.jsonc index f2613f1b..1429356e 100644 --- a/apps/dex-analysis/wrangler.jsonc +++ b/apps/dex-analysis/wrangler.jsonc @@ -2,7 +2,7 @@ * For more details on how to configure Wrangler, refer to: * https://developers.cloudflare.com/workers/wrangler/configuration/ */ - { +{ "$schema": "node_modules/wrangler/config-schema.json", "main": "src/index.ts", "compatibility_date": "2025-03-10", @@ -80,7 +80,7 @@ "binding": "MCP_METRICS", "dataset": "mcp-metrics-staging" } - ], + ] }, "production": { "name": "mcp-cloudflare-workers-dex-production", @@ -113,7 +113,7 @@ "binding": "MCP_METRICS", "dataset": "mcp-metrics-production" } - ], + ] } } } diff --git a/apps/docs-autorag/.dev.vars.example b/apps/docs-autorag/.dev.vars.example deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/radar/.dev.vars.example b/apps/radar/.dev.vars.example index 4a3952bf..7c6e82bf 100644 --- a/apps/radar/.dev.vars.example +++ b/apps/radar/.dev.vars.example @@ -1,3 +1,6 @@ CLOUDFLARE_CLIENT_ID= CLOUDFLARE_CLIENT_SECRET= URL_SCANNER_API_TOKEN= +DEV_DISABLE_OAUTH= +DEV_CLOUDFLARE_API_TOKEN= +DEV_CLOUDFLARE_EMAIL= \ No newline at end of file diff --git a/apps/radar/CONTRIBUTING.md b/apps/radar/CONTRIBUTING.md new file mode 100644 index 00000000..12e6eb0f --- /dev/null +++ b/apps/radar/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Setup + +If you'd like to iterate and test your MCP server, you can do so in local development. + +## Local Development + +1. Create a `.dev.vars` file in your project root: + + If you're a Cloudflare employee: + + ``` + CLOUDFLARE_CLIENT_ID=your_development_cloudflare_client_id + CLOUDFLARE_CLIENT_SECRET=your_development_cloudflare_client_secret + URL_SCANNER_API_TOKEN=your_development_url_scanner_api_token + ``` + + If you're an external contributor, you can provide a development API token: + + ``` + DEV_DISABLE_OAUTH=true + # This is your global api token + DEV_CLOUDFLARE_API_TOKEN=your_development_api_token + URL_SCANNER_API_TOKEN=your_development_url_scanner_api_token + ``` + +2. Start the local development server: + + ```bash + npx wrangler dev + ``` + +3. To test locally, open Inspector, and connect to `http://localhost:8976/sse`. + Once you follow the prompts, you'll be able to "List Tools". You can also connect with any MCP client. + +## Deploying the Worker ( Cloudflare employees only ) + +Set secrets via Wrangler: + +```bash +npx wrangler secret put CLOUDFLARE_CLIENT_ID -e +npx wrangler secret put CLOUDFLARE_CLIENT_SECRET -e +npx wrangler secret put URL_SCANNER_API_TOKEN -e +``` + +## Set up a KV namespace + +Create the KV namespace: + +```bash +npx wrangler kv namespace create "OAUTH_KV" +``` + +Then, update the Wrangler file with the generated KV namespace ID. + +## Deploy & Test + +Deploy the MCP server to make it available on your workers.dev domain: + +```bash +npx wrangler deploy -e +``` + +Test the remote server using [Inspector](https://modelcontextprotocol.io/docs/tools/inspector): + +```bash +npx @modelcontextprotocol/inspector@latest +``` diff --git a/apps/radar/README.md b/apps/radar/README.md index 82de54bb..022f12f1 100644 --- a/apps/radar/README.md +++ b/apps/radar/README.md @@ -34,10 +34,11 @@ This MCP server is still a work in progress, and we plan to add more tools in th - `Give me rank details for google.com in March 2025.` - `Scan https://example.com.` -## Access the remote MCP server from Claude Desktop +## Access the remote MCP server from from any MCP Client -Open Claude Desktop and navigate to `Settings -> Developer -> Edit Config`. -This opens the configuration file that controls which MCP servers Claude can access. +If your MCP client has first class support for remote MCP servers, the client will provide a way to accept the server URL (`https://radar.mcp.cloudflare.com`) directly within its interface (for example in[Cloudflare AI Playground](https://playground.ai.cloudflare.com/)). + +If your client does not yet support remote MCP servers, you will need to set up its resepective configuration file using mcp-remote (https://www.npmjs.com/package/mcp-remote) to specify which servers your client can access. Replace the content with the following configuration: @@ -52,66 +53,6 @@ Replace the content with the following configuration: } ``` -Once you restart Claude Desktop, a browser window will open showing your OAuth login page. -Complete the authentication flow to grant Claude access to your MCP server. -After you grant access, the tools will become available for you to use. - -## Setup - -#### Secrets - -Set secrets via Wrangler: - -```bash -npx wrangler secret put CLOUDFLARE_CLIENT_ID -e -npx wrangler secret put CLOUDFLARE_CLIENT_SECRET -e -npx wrangler secret put URL_SCANNER_API_TOKEN -e -``` - -#### Set up a KV namespace - -Create the KV namespace: - -```bash -npx wrangler kv namespace create "OAUTH_KV" -``` - -Then, update the Wrangler file with the generated KV namespace ID. - -#### Deploy & Test - -Deploy the MCP server to make it available on your workers.dev domain: - -```bash -npx wrangler deploy -e -``` - -Test the remote server using [Inspector](https://modelcontextprotocol.io/docs/tools/inspector): - -```bash -npx @modelcontextprotocol/inspector@latest -``` - -## Local Development - -If you'd like to iterate and test your MCP server, you can do so in local development. -This will require you to create another OAuth App on Cloudflare: - -1. Create a `.dev.vars` file in your project root with: - - ``` - CLOUDFLARE_CLIENT_ID=your_development_cloudflare_client_id - CLOUDFLARE_CLIENT_SECRET=your_development_cloudflare_client_secret - URL_SCANNER_API_TOKEN=your_development_url_scanner_api_token - ``` - -2. Start the local development server: - - ```bash - npx wrangler dev - ``` - -3. To test locally, open Inspector, and connect to `http://localhost:8976/sse`. - Once you follow the prompts, you'll be able to "List Tools". +Once you've set up your configuration file, restart MCP client and a browser window will open showing your OAuth login page. Proceed through the authentication flow to grant the client access to your MCP server. After you grant access, the tools will become available for you to use. - You can also connect to Claude Desktop. +Interested in contributing, and running this server locally? See [CONTRIBUTING.md](CONTRIBUTING.md) to get started. diff --git a/apps/radar/src/context.ts b/apps/radar/src/context.ts index a9f42029..e9572078 100644 --- a/apps/radar/src/context.ts +++ b/apps/radar/src/context.ts @@ -11,4 +11,7 @@ export interface Env { URL_SCANNER_API_TOKEN: string MCP_OBJECT: DurableObjectNamespace MCP_METRICS: AnalyticsEngineDataset + DEV_DISABLE_OAUTH: string + DEV_CLOUDFLARE_API_TOKEN: string + DEV_CLOUDFLARE_EMAIL: string } diff --git a/apps/radar/src/index.ts b/apps/radar/src/index.ts index f2ba74ba..fdef3e56 100644 --- a/apps/radar/src/index.ts +++ b/apps/radar/src/index.ts @@ -3,9 +3,11 @@ import { McpAgent } from 'agents/mcp' import { createAuthHandlers, + getUserAndAccounts, handleTokenExchangeCallback, } from '@repo/mcp-common/src/cloudflare-oauth-handler' import { getEnv } from '@repo/mcp-common/src/env' +import { RequiredScopes } from '@repo/mcp-common/src/scopes' import { CloudflareMCPServer } from '@repo/mcp-common/src/server' import { MetricsTracker } from '@repo/mcp-observability' @@ -69,21 +71,44 @@ export class RadarMCP extends McpAgent { // TODO add radar:read and url_scanner:write scopes once they are available // Also remove URL_SCANNER_API_TOKEN env var -const RadarScopes = { - 'user:read': 'See your user info such as name, email address, and account memberships.', - offline_access: 'Grants refresh tokens for long-lived access.', -} as const - -export default new OAuthProvider({ - apiRoute: '/sse', - apiHandler: RadarMCP.mount('/sse'), - // @ts-ignore - defaultHandler: createAuthHandlers({ scopes: RadarScopes, metrics }), - authorizeEndpoint: '/oauth/authorize', - tokenEndpoint: '/token', - tokenExchangeCallback: (options) => - handleTokenExchangeCallback(options, env.CLOUDFLARE_CLIENT_ID, env.CLOUDFLARE_CLIENT_SECRET), - // Cloudflare access token TTL - accessTokenTTL: 3600, - clientRegistrationEndpoint: '/register', -}) +const RadarScopes = { ...RequiredScopes } as const + +// TODO: Move this in to mcp-common +async function handleDevMode(req: Request, env: Env, ctx: ExecutionContext) { + const { user, accounts } = await getUserAndAccounts(env.DEV_CLOUDFLARE_API_TOKEN, { + 'X-Auth-Email': env.DEV_CLOUDFLARE_EMAIL, + 'X-Auth-Key': env.DEV_CLOUDFLARE_API_TOKEN, + }) + ctx.props = { + accessToken: env.DEV_CLOUDFLARE_API_TOKEN, + user, + accounts, + } as Props + return RadarMCP.mount('/sse').fetch(req, env, ctx) +} + +export default { + fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { + if (env.ENVIRONMENT === 'development' && env.DEV_DISABLE_OAUTH === 'true') { + return await handleDevMode(req, env, ctx) + } + + return new OAuthProvider({ + apiRoute: '/sse', + apiHandler: RadarMCP.mount('/sse'), + // @ts-ignore + defaultHandler: createAuthHandlers({ scopes: RadarScopes, metrics }), + authorizeEndpoint: '/oauth/authorize', + tokenEndpoint: '/token', + tokenExchangeCallback: (options) => + handleTokenExchangeCallback( + options, + env.CLOUDFLARE_CLIENT_ID, + env.CLOUDFLARE_CLIENT_SECRET + ), + // Cloudflare access token TTL + accessTokenTTL: 3600, + clientRegistrationEndpoint: '/register', + }).fetch(req, env, ctx) + }, +} diff --git a/apps/sandbox-container/.dev.vars.example b/apps/sandbox-container/.dev.vars.example index e6c0feb7..c087f669 100644 --- a/apps/sandbox-container/.dev.vars.example +++ b/apps/sandbox-container/.dev.vars.example @@ -1,2 +1,5 @@ CLOUDFLARE_CLIENT_ID= -CLOUDFLARE_CLIENT_SECRET= \ No newline at end of file +CLOUDFLARE_CLIENT_SECRET= +DEV_DISABLE_OAUTH= +DEV_CLOUDFLARE_API_TOKEN= +DEV_CLOUDFLARE_EMAIL= \ No newline at end of file diff --git a/apps/sandbox-container/server/context.ts b/apps/sandbox-container/server/context.ts index 83829b35..e2600f38 100644 --- a/apps/sandbox-container/server/context.ts +++ b/apps/sandbox-container/server/context.ts @@ -12,4 +12,7 @@ export interface Env { CONTAINER_MANAGER: DurableObjectNamespace MCP_METRICS: AnalyticsEngineDataset AI: Ai + DEV_DISABLE_OAUTH: string + DEV_CLOUDFLARE_API_TOKEN: string + DEV_CLOUDFLARE_EMAIL: string } diff --git a/apps/sandbox-container/server/index.ts b/apps/sandbox-container/server/index.ts index b7de9415..1343e4be 100644 --- a/apps/sandbox-container/server/index.ts +++ b/apps/sandbox-container/server/index.ts @@ -2,6 +2,7 @@ import OAuthProvider from '@cloudflare/workers-oauth-provider' import { createAuthHandlers, + getUserAndAccounts, handleTokenExchangeCallback, } from '@repo/mcp-common/src/cloudflare-oauth-handler' import { getEnv } from '@repo/mcp-common/src/env' @@ -39,8 +40,22 @@ const ContainerScopes = { 'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.', } as const +// TODO: Move this in to mcp-common +async function handleDevMode(req: Request, env: Env, ctx: ExecutionContext) { + const { user, accounts } = await getUserAndAccounts(env.DEV_CLOUDFLARE_API_TOKEN, { + 'X-Auth-Email': env.DEV_CLOUDFLARE_EMAIL, + 'X-Auth-Key': env.DEV_CLOUDFLARE_API_TOKEN, + }) + ctx.props = { + accessToken: env.DEV_CLOUDFLARE_API_TOKEN, + user, + accounts, + } as Props + return ContainerMcpAgent.mount('/sse').fetch(req, env, ctx) +} + export default { - fetch: (req: Request, env: Env, ctx: ExecutionContext) => { + fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { // @ts-ignore if (env.ENVIRONMENT === 'test') { ctx.props = { @@ -58,6 +73,10 @@ export default { ) } + if (env.ENVIRONMENT === 'dev' && env.DEV_DISABLE_OAUTH === 'true') { + return await handleDevMode(req, env, ctx) + } + return new OAuthProvider({ apiRoute: '/sse', apiHandler: ContainerMcpAgent.mount('/sse', { binding: 'CONTAINER_MCP_AGENT' }), diff --git a/apps/workers-bindings/.dev.vars.example b/apps/workers-bindings/.dev.vars.example index a84c1ea9..c087f669 100644 --- a/apps/workers-bindings/.dev.vars.example +++ b/apps/workers-bindings/.dev.vars.example @@ -1,2 +1,5 @@ CLOUDFLARE_CLIENT_ID= CLOUDFLARE_CLIENT_SECRET= +DEV_DISABLE_OAUTH= +DEV_CLOUDFLARE_API_TOKEN= +DEV_CLOUDFLARE_EMAIL= \ No newline at end of file diff --git a/apps/workers-bindings/src/context.ts b/apps/workers-bindings/src/context.ts index e37a249b..7f66e521 100644 --- a/apps/workers-bindings/src/context.ts +++ b/apps/workers-bindings/src/context.ts @@ -11,4 +11,7 @@ export interface Env { MCP_OBJECT: DurableObjectNamespace USER_DETAILS: DurableObjectNamespace MCP_METRICS: AnalyticsEngineDataset + DEV_DISABLE_OAUTH: string + DEV_CLOUDFLARE_API_TOKEN: string + DEV_CLOUDFLARE_EMAIL: string } diff --git a/apps/workers-bindings/src/index.ts b/apps/workers-bindings/src/index.ts index 0db32651..c799dacd 100644 --- a/apps/workers-bindings/src/index.ts +++ b/apps/workers-bindings/src/index.ts @@ -3,6 +3,7 @@ import { McpAgent } from 'agents/mcp' import { createAuthHandlers, + getUserAndAccounts, handleTokenExchangeCallback, } from '@repo/mcp-common/src/cloudflare-oauth-handler' import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details' @@ -107,17 +108,42 @@ const BindingsScopes = { 'd1:write': 'Create, read, and write to D1 databases', } as const -// Export the OAuth handler as the default -export default new OAuthProvider({ - apiRoute: '/sse', - apiHandler: WorkersBindingsMCP.mount('/sse'), - // @ts-ignore - defaultHandler: createAuthHandlers({ scopes: BindingsScopes, metrics }), - authorizeEndpoint: '/oauth/authorize', - tokenEndpoint: '/token', - tokenExchangeCallback: (options) => - handleTokenExchangeCallback(options, env.CLOUDFLARE_CLIENT_ID, env.CLOUDFLARE_CLIENT_SECRET), - // Cloudflare access token TTL - accessTokenTTL: 3600, - clientRegistrationEndpoint: '/register', -}) +// TODO: Move this in to mcp-common +async function handleDevMode(req: Request, env: Env, ctx: ExecutionContext) { + const { user, accounts } = await getUserAndAccounts(env.DEV_CLOUDFLARE_API_TOKEN, { + 'X-Auth-Email': env.DEV_CLOUDFLARE_EMAIL, + 'X-Auth-Key': env.DEV_CLOUDFLARE_API_TOKEN, + }) + ctx.props = { + accessToken: env.DEV_CLOUDFLARE_API_TOKEN, + user, + accounts, + } as Props + return WorkersBindingsMCP.mount('/sse').fetch(req, env, ctx) +} + +export default { + fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { + if (env.ENVIRONMENT === 'development' && env.DEV_DISABLE_OAUTH === 'true') { + return await handleDevMode(req, env, ctx) + } + + return new OAuthProvider({ + apiRoute: '/sse', + apiHandler: WorkersBindingsMCP.mount('/sse'), + // @ts-ignore + defaultHandler: createAuthHandlers({ scopes: BindingsScopes, metrics }), + authorizeEndpoint: '/oauth/authorize', + tokenEndpoint: '/token', + tokenExchangeCallback: (options) => + handleTokenExchangeCallback( + options, + env.CLOUDFLARE_CLIENT_ID, + env.CLOUDFLARE_CLIENT_SECRET + ), + // Cloudflare access token TTL + accessTokenTTL: 3600, + clientRegistrationEndpoint: '/register', + }).fetch(req, env, ctx) + }, +} diff --git a/apps/workers-observability/src/context.ts b/apps/workers-observability/src/context.ts index 99163a6b..52c15885 100644 --- a/apps/workers-observability/src/context.ts +++ b/apps/workers-observability/src/context.ts @@ -15,4 +15,7 @@ export interface Env { SENTRY_ACCESS_CLIENT_SECRET: string GIT_HASH: string SENTRY_DSN: string + DEV_DISABLE_OAUTH: string + DEV_CLOUDFLARE_API_TOKEN: string + DEV_CLOUDFLARE_EMAIL: string } diff --git a/apps/workers-observability/src/index.ts b/apps/workers-observability/src/index.ts index 9dd02cf0..afdbe4ca 100644 --- a/apps/workers-observability/src/index.ts +++ b/apps/workers-observability/src/index.ts @@ -3,6 +3,7 @@ import { McpAgent } from 'agents/mcp' import { createAuthHandlers, + getUserAndAccounts, handleTokenExchangeCallback, } from '@repo/mcp-common/src/cloudflare-oauth-handler' import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details' @@ -125,16 +126,42 @@ const ObservabilityScopes = { 'workers_observability:read': 'See observability logs for your account', } as const -export default new OAuthProvider({ - apiRoute: '/sse', - apiHandler: ObservabilityMCP.mount('/sse'), - // @ts-ignore - defaultHandler: createAuthHandlers({ scopes: ObservabilityScopes, metrics }), - authorizeEndpoint: '/oauth/authorize', - tokenEndpoint: '/token', - tokenExchangeCallback: (options) => - handleTokenExchangeCallback(options, env.CLOUDFLARE_CLIENT_ID, env.CLOUDFLARE_CLIENT_SECRET), - // Cloudflare access token TTL - accessTokenTTL: 3600, - clientRegistrationEndpoint: '/register', -}) +// TODO: Move this in to mcp-common +async function handleDevMode(req: Request, env: Env, ctx: ExecutionContext) { + const { user, accounts } = await getUserAndAccounts(env.DEV_CLOUDFLARE_API_TOKEN, { + 'X-Auth-Email': env.DEV_CLOUDFLARE_EMAIL, + 'X-Auth-Key': env.DEV_CLOUDFLARE_API_TOKEN, + }) + ctx.props = { + accessToken: env.DEV_CLOUDFLARE_API_TOKEN, + user, + accounts, + } as Props + return ObservabilityMCP.mount('/sse').fetch(req, env, ctx) +} + +export default { + fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { + if (env.ENVIRONMENT === 'development' && env.DEV_DISABLE_OAUTH === 'true') { + return await handleDevMode(req, env, ctx) + } + + return new OAuthProvider({ + apiRoute: '/sse', + apiHandler: ObservabilityMCP.mount('/sse'), + // @ts-ignore + defaultHandler: createAuthHandlers({ scopes: ObservabilityScopes, metrics }), + authorizeEndpoint: '/oauth/authorize', + tokenEndpoint: '/token', + tokenExchangeCallback: (options) => + handleTokenExchangeCallback( + options, + env.CLOUDFLARE_CLIENT_ID, + env.CLOUDFLARE_CLIENT_SECRET + ), + // Cloudflare access token TTL + accessTokenTTL: 3600, + clientRegistrationEndpoint: '/register', + }).fetch(req, env, ctx) + }, +} diff --git a/packages/mcp-common/src/cloudflare-oauth-handler.ts b/packages/mcp-common/src/cloudflare-oauth-handler.ts index 44a432cb..c3afa880 100644 --- a/packages/mcp-common/src/cloudflare-oauth-handler.ts +++ b/packages/mcp-common/src/cloudflare-oauth-handler.ts @@ -63,34 +63,22 @@ const AccountResponseSchema = z.object({ ), }) -async function getTokenAndUser( - c: Context, - code: string, - code_verifier: string -): Promise<{ - accessToken: string - refreshToken: string - user: UserSchema['result'] - accounts: AccountSchema['result'] -}> { - // Exchange the code for an access token - const { access_token: accessToken, refresh_token: refreshToken } = await getAuthToken({ - client_id: c.env.CLOUDFLARE_CLIENT_ID, - client_secret: c.env.CLOUDFLARE_CLIENT_SECRET, - redirect_uri: new URL('/oauth/callback', c.req.url).href, - code, - code_verifier, - }) +export async function getUserAndAccounts( + accessToken: string, + devModeHeaders?: HeadersInit +): Promise<{ user: UserSchema['result']; accounts: AccountSchema['result'] }> { + const headers = devModeHeaders + ? devModeHeaders + : { + Authorization: `Bearer ${accessToken}`, + } + const [userResponse, accountsResponse] = await Promise.all([ fetch('https://api.cloudflare.com/client/v4/user', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, + headers, }), fetch('https://api.cloudflare.com/client/v4/accounts', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, + headers, }), ]) @@ -107,6 +95,30 @@ async function getTokenAndUser( const { result: user } = UserResponseSchema.parse(await userResponse.json()) const { result: accounts } = AccountResponseSchema.parse(await accountsResponse.json()) + return { user, accounts } +} + +async function getTokenAndUserDetails( + c: Context, + code: string, + code_verifier: string +): Promise<{ + accessToken: string + refreshToken: string + user: UserSchema['result'] + accounts: AccountSchema['result'] +}> { + // Exchange the code for an access token + const { access_token: accessToken, refresh_token: refreshToken } = await getAuthToken({ + client_id: c.env.CLOUDFLARE_CLIENT_ID, + client_secret: c.env.CLOUDFLARE_CLIENT_SECRET, + redirect_uri: new URL('/oauth/callback', c.req.url).href, + code, + code_verifier, + }) + + const { user, accounts } = await getUserAndAccounts(accessToken) + return { accessToken, refreshToken, user, accounts } } @@ -217,7 +229,7 @@ export function createAuthHandlers({ } const [{ accessToken, refreshToken, user, accounts }] = await Promise.all([ - getTokenAndUser(c, code, oauthReqInfo.codeVerifier), + getTokenAndUserDetails(c, code, oauthReqInfo.codeVerifier), c.env.OAUTH_PROVIDER.createClient({ clientId: oauthReqInfo.clientId, tokenEndpointAuthMethod: 'none', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d816bd1..73109303 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: link:../../packages/mcp-common agents: specifier: 0.0.67 - version: 0.0.67(@cloudflare/workers-types@4.20250410.0)(react@17.0.2) + version: 0.0.67(@cloudflare/workers-types@4.20250416.0)(react@17.0.2) cloudflare: specifier: 4.2.0 version: 4.2.0 @@ -77,10 +77,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.8.14 - version: 0.8.14(@cloudflare/workers-types@4.20250410.0)(@vitest/runner@3.0.9)(@vitest/snapshot@3.0.9)(vitest@3.0.9) - '@cloudflare/workers-types': - specifier: 4.20250410.0 - version: 4.20250410.0 + version: 0.8.14(@cloudflare/workers-types@4.20250416.0)(@vitest/runner@3.0.9)(@vitest/snapshot@3.0.9)(vitest@3.0.9) '@types/jsonwebtoken': specifier: 9.0.9 version: 9.0.9 @@ -95,7 +92,7 @@ importers: version: 3.0.9(@types/node@22.14.1)(@vitest/ui@3.0.9)(lightningcss@1.29.2) wrangler: specifier: 4.10.0 - version: 4.10.0(@cloudflare/workers-types@4.20250410.0) + version: 4.10.0(@cloudflare/workers-types@4.20250416.0) apps/demo-day: dependencies: @@ -153,7 +150,7 @@ importers: version: link:../../packages/mcp-observability agents: specifier: 0.0.67 - version: 0.0.67(@cloudflare/workers-types@4.20250410.0)(react@17.0.2) + version: 0.0.67(@cloudflare/workers-types@4.20250416.0)(react@17.0.2) cloudflare: specifier: 4.2.0 version: 4.2.0 @@ -166,10 +163,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.8.14 - version: 0.8.14(@cloudflare/workers-types@4.20250410.0)(@vitest/runner@3.0.9)(@vitest/snapshot@3.0.9)(vitest@3.0.9) - '@cloudflare/workers-types': - specifier: 4.20250410.0 - version: 4.20250410.0 + version: 0.8.14(@cloudflare/workers-types@4.20250416.0)(@vitest/runner@3.0.9)(@vitest/snapshot@3.0.9)(vitest@3.0.9) '@types/jsonwebtoken': specifier: 9.0.9 version: 9.0.9 @@ -184,7 +178,7 @@ importers: version: 3.0.9(@types/node@22.14.1)(@vitest/ui@3.0.9)(lightningcss@1.29.2) wrangler: specifier: 4.10.0 - version: 4.10.0(@cloudflare/workers-types@4.20250410.0) + version: 4.10.0(@cloudflare/workers-types@4.20250416.0) apps/docs-autorag: dependencies: @@ -4075,24 +4069,6 @@ snapshots: optionalDependencies: workerd: 1.20250409.0 - '@cloudflare/vitest-pool-workers@0.8.14(@cloudflare/workers-types@4.20250410.0)(@vitest/runner@3.0.9)(@vitest/snapshot@3.0.9)(vitest@3.0.9)': - dependencies: - '@vitest/runner': 3.0.9 - '@vitest/snapshot': 3.0.9 - birpc: 0.2.14 - cjs-module-lexer: 1.3.1 - devalue: 4.3.3 - esbuild: 0.25.1 - miniflare: 4.20250408.0 - semver: 7.7.1 - vitest: 3.0.9(@types/node@22.14.1)(@vitest/ui@3.0.9)(lightningcss@1.29.2) - wrangler: 4.9.1(@cloudflare/workers-types@4.20250410.0) - zod: 3.24.2 - transitivePeerDependencies: - - '@cloudflare/workers-types' - - bufferutil - - utf-8-validate - '@cloudflare/vitest-pool-workers@0.8.14(@cloudflare/workers-types@4.20250416.0)(@vitest/runner@3.0.9)(@vitest/snapshot@3.0.9)(vitest@3.0.9)': dependencies: '@vitest/runner': 3.0.9 @@ -4731,20 +4707,6 @@ snapshots: dependencies: humanize-ms: 1.2.1 - agents@0.0.67(@cloudflare/workers-types@4.20250410.0)(react@17.0.2): - dependencies: - '@modelcontextprotocol/sdk': 1.10.2 - ai: 4.3.10(react@17.0.2)(zod@3.24.3) - cron-schedule: 5.0.4 - nanoid: 5.1.5 - partyserver: 0.0.67(@cloudflare/workers-types@4.20250410.0) - partysocket: 1.1.3 - react: 17.0.2 - zod: 3.24.3 - transitivePeerDependencies: - - '@cloudflare/workers-types' - - supports-color - agents@0.0.67(@cloudflare/workers-types@4.20250416.0)(react@17.0.2): dependencies: '@modelcontextprotocol/sdk': 1.10.2 @@ -6340,11 +6302,6 @@ snapshots: parseurl@1.3.3: {} - partyserver@0.0.67(@cloudflare/workers-types@4.20250410.0): - dependencies: - '@cloudflare/workers-types': 4.20250410.0 - nanoid: 5.1.5 - partyserver@0.0.67(@cloudflare/workers-types@4.20250416.0): dependencies: '@cloudflare/workers-types': 4.20250416.0 @@ -7152,24 +7109,6 @@ snapshots: dependencies: '@cloudflare/workers-types': 4.20250410.0 - wrangler@4.10.0(@cloudflare/workers-types@4.20250410.0): - dependencies: - '@cloudflare/kv-asset-handler': 0.4.0 - '@cloudflare/unenv-preset': 2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250409.0) - blake3-wasm: 2.1.5 - esbuild: 0.25.1 - miniflare: 4.20250409.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.15 - workerd: 1.20250409.0 - optionalDependencies: - '@cloudflare/workers-types': 4.20250410.0 - fsevents: 2.3.3 - sharp: 0.33.5 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - wrangler@4.10.0(@cloudflare/workers-types@4.20250416.0): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 @@ -7188,24 +7127,6 @@ snapshots: - bufferutil - utf-8-validate - wrangler@4.9.1(@cloudflare/workers-types@4.20250410.0): - dependencies: - '@cloudflare/kv-asset-handler': 0.4.0 - '@cloudflare/unenv-preset': 2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250408.0) - blake3-wasm: 2.1.5 - esbuild: 0.25.1 - miniflare: 4.20250408.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.15 - workerd: 1.20250408.0 - optionalDependencies: - '@cloudflare/workers-types': 4.20250410.0 - fsevents: 2.3.3 - sharp: 0.33.5 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - wrangler@4.9.1(@cloudflare/workers-types@4.20250416.0): dependencies: '@cloudflare/kv-asset-handler': 0.4.0