This is page 4 of 27. Use http://codebase.md/cloudflare/mcp-server-cloudflare?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .changeset │ ├── config.json │ └── README.md ├── .dockerignore ├── .editorconfig ├── .eslintrc.cjs ├── .github │ ├── actions │ │ └── setup │ │ └── action.yml │ ├── ISSUE_TEMPLATE │ │ └── bug_report.md │ └── workflows │ ├── branches.yml │ ├── main.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.cjs ├── .syncpackrc.cjs ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── apps │ ├── ai-gateway │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── CONTRIBUTING.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── ai-gateway.app.ts │ │ │ ├── ai-gateway.context.ts │ │ │ ├── tools │ │ │ │ └── ai-gateway.tools.ts │ │ │ └── types.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── auditlogs │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── auditlogs.app.ts │ │ │ ├── auditlogs.context.ts │ │ │ └── tools │ │ │ └── auditlogs.tools.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── autorag │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── CONTRIBUTING.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── autorag.app.ts │ │ │ ├── autorag.context.ts │ │ │ ├── tools │ │ │ │ └── autorag.tools.ts │ │ │ └── types.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── browser-rendering │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── CONTRIBUTING.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── browser.app.ts │ │ │ ├── browser.context.ts │ │ │ └── tools │ │ │ └── browser.tools.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── cloudflare-one-casb │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── cf1-casb.app.ts │ │ │ ├── cf1-casb.context.ts │ │ │ └── tools │ │ │ └── integrations.tools.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── demo-day │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── frontend │ │ │ ├── index.html │ │ │ ├── public │ │ │ │ ├── anthropic.svg │ │ │ │ ├── asana.svg │ │ │ │ ├── atlassian.svg │ │ │ │ ├── canva.svg │ │ │ │ ├── cloudflare_logo.svg │ │ │ │ ├── cloudflare.svg │ │ │ │ ├── dina.jpg │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ ├── favicon.png │ │ │ │ ├── intercom.svg │ │ │ │ ├── linear.svg │ │ │ │ ├── matt.jpg │ │ │ │ ├── mcp_demo_day.svg │ │ │ │ ├── mcpog.png │ │ │ │ ├── more.svg │ │ │ │ ├── paypal.svg │ │ │ │ ├── pete.jpeg │ │ │ │ ├── sentry.svg │ │ │ │ ├── special_guest.png │ │ │ │ ├── square.svg │ │ │ │ ├── stripe.svg │ │ │ │ ├── sunil.jpg │ │ │ │ └── webflow.svg │ │ │ ├── script.js │ │ │ └── styles.css │ │ ├── package.json │ │ ├── src │ │ │ └── demo-day.app.ts │ │ ├── tsconfig.json │ │ ├── worker-configuration.d.ts │ │ └── wrangler.json │ ├── dex-analysis │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── dex-analysis.app.ts │ │ │ ├── dex-analysis.context.ts │ │ │ ├── tools │ │ │ │ └── dex-analysis.tools.ts │ │ │ └── warp_diag_reader.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── dns-analytics │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── CONTRIBUTING.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── dns-analytics.app.ts │ │ │ ├── dns-analytics.context.ts │ │ │ └── tools │ │ │ └── dex-analytics.tools.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── docs-autorag │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── docs-autorag.app.ts │ │ │ ├── docs-autorag.context.ts │ │ │ └── tools │ │ │ └── docs-autorag.tools.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── docs-vectorize │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── docs-vectorize.app.ts │ │ │ └── docs-vectorize.context.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── graphql │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── graphql.app.ts │ │ │ ├── graphql.context.ts │ │ │ └── tools │ │ │ └── graphql.tools.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── logpush │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── logpush.app.ts │ │ │ ├── logpush.context.ts │ │ │ └── tools │ │ │ └── logpush.tools.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── radar │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── CONTRIBUTING.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── radar.app.ts │ │ │ ├── radar.context.ts │ │ │ ├── tools │ │ │ │ ├── radar.tools.ts │ │ │ │ └── url-scanner.tools.ts │ │ │ ├── types │ │ │ │ ├── radar.ts │ │ │ │ └── url-scanner.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── sandbox-container │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── container │ │ │ ├── fileUtils.spec.ts │ │ │ ├── fileUtils.ts │ │ │ ├── sandbox.container.app.ts │ │ │ └── tsconfig.json │ │ ├── CONTRIBUTING.md │ │ ├── Dockerfile │ │ ├── evals │ │ │ ├── exec.eval.ts │ │ │ ├── files.eval.ts │ │ │ ├── initialize.eval.ts │ │ │ └── utils.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── server │ │ │ ├── containerHelpers.ts │ │ │ ├── containerManager.ts │ │ │ ├── containerMcp.ts │ │ │ ├── metrics.ts │ │ │ ├── prompts.ts │ │ │ ├── sandbox.server.app.ts │ │ │ ├── sandbox.server.context.ts │ │ │ ├── userContainer.ts │ │ │ ├── utils.spec.ts │ │ │ └── utils.ts │ │ ├── shared │ │ │ ├── consts.ts │ │ │ └── schema.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.evals.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── workers-bindings │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── CONTRIBUTING.md │ │ ├── evals │ │ │ ├── accounts.eval.ts │ │ │ ├── hyperdrive.eval.ts │ │ │ ├── kv_namespaces.eval.ts │ │ │ ├── types.d.ts │ │ │ └── utils.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── bindings.app.ts │ │ │ └── bindings.context.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.evals.ts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ ├── workers-builds │ │ ├── .dev.vars.example │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── CONTRIBUTING.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── tools │ │ │ │ └── workers-builds.tools.ts │ │ │ ├── workers-builds.app.ts │ │ │ └── workers-builds.context.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vite.config.mts │ │ ├── vitest.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ └── workers-observability │ ├── .dev.vars.example │ ├── .eslintrc.cjs │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── package.json │ ├── README.md │ ├── src │ │ ├── tools │ │ │ └── workers-observability.tools.ts │ │ ├── workers-observability.app.ts │ │ └── workers-observability.context.ts │ ├── tsconfig.json │ ├── types.d.ts │ ├── vitest.config.ts │ ├── worker-configuration.d.ts │ └── wrangler.jsonc ├── CONTRIBUTING.md ├── implementation-guides │ ├── evals.md │ ├── tools.md │ └── type-validators.md ├── LICENSE ├── package.json ├── packages │ ├── eslint-config │ │ ├── CHANGELOG.md │ │ ├── default.cjs │ │ ├── package.json │ │ └── README.md │ ├── eval-tools │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── runTask.ts │ │ │ ├── scorers.ts │ │ │ └── test-models.ts │ │ ├── tsconfig.json │ │ ├── worker-configuration.d.ts │ │ └── wrangler.json │ ├── mcp-common │ │ ├── .eslintrc.cjs │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── api │ │ │ │ ├── account.api.ts │ │ │ │ ├── cf1-integration.api.ts │ │ │ │ ├── workers-builds.api.ts │ │ │ │ ├── workers-observability.api.ts │ │ │ │ ├── workers.api.ts │ │ │ │ └── zone.api.ts │ │ │ ├── api-handler.ts │ │ │ ├── api-token-mode.ts │ │ │ ├── cloudflare-api.ts │ │ │ ├── cloudflare-auth.ts │ │ │ ├── cloudflare-oauth-handler.ts │ │ │ ├── config.ts │ │ │ ├── constants.ts │ │ │ ├── durable-kv-store.ts │ │ │ ├── durable-objects │ │ │ │ └── user_details.do.ts │ │ │ ├── env.ts │ │ │ ├── format.spec.ts │ │ │ ├── format.ts │ │ │ ├── get-props.ts │ │ │ ├── mcp-error.ts │ │ │ ├── poll.ts │ │ │ ├── prompts │ │ │ │ └── docs-vectorize.prompts.ts │ │ │ ├── scopes.ts │ │ │ ├── sentry.ts │ │ │ ├── server.ts │ │ │ ├── tools │ │ │ │ ├── account.tools.ts │ │ │ │ ├── d1.tools.ts │ │ │ │ ├── docs-vectorize.tools.ts │ │ │ │ ├── hyperdrive.tools.ts │ │ │ │ ├── kv_namespace.tools.ts │ │ │ │ ├── r2_bucket.tools.ts │ │ │ │ ├── worker.tools.ts │ │ │ │ └── zone.tools.ts │ │ │ ├── types │ │ │ │ ├── cf1-integrations.types.ts │ │ │ │ ├── cloudflare-mcp-agent.types.ts │ │ │ │ ├── d1.types.ts │ │ │ │ ├── hyperdrive.types.ts │ │ │ │ ├── kv_namespace.types.ts │ │ │ │ ├── r2_bucket.types.ts │ │ │ │ ├── shared.types.ts │ │ │ │ ├── tools.types.ts │ │ │ │ ├── workers-builds.types.ts │ │ │ │ ├── workers-logs.types.ts │ │ │ │ └── workers.types.ts │ │ │ ├── utils.spec.ts │ │ │ ├── utils.ts │ │ │ └── v4-api.ts │ │ ├── tests │ │ │ └── utils │ │ │ └── cloudflare-mock.ts │ │ ├── tsconfig.json │ │ ├── types.d.ts │ │ ├── vitest.config.ts │ │ └── worker-configuration.d.ts │ ├── mcp-observability │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── analytics-engine.ts │ │ │ ├── index.ts │ │ │ └── metrics.ts │ │ ├── tsconfig.json │ │ └── worker-configuration.d.ts │ ├── tools │ │ ├── .eslintrc.cjs │ │ ├── bin │ │ │ ├── run-changeset-new │ │ │ ├── run-eslint-workers │ │ │ ├── run-fix-deps │ │ │ ├── run-tsc │ │ │ ├── run-turbo │ │ │ ├── run-vitest │ │ │ ├── run-vitest-ci │ │ │ ├── run-wrangler-deploy │ │ │ ├── run-wrangler-types │ │ │ └── runx │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── bin │ │ │ │ └── runx.ts │ │ │ ├── changesets.spec.ts │ │ │ ├── changesets.ts │ │ │ ├── cmd │ │ │ │ └── deploy-published-packages.ts │ │ │ ├── proc.ts │ │ │ ├── test │ │ │ │ ├── fixtures │ │ │ │ │ └── changesets │ │ │ │ │ ├── empty │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ ├── invalid-json │ │ │ │ │ │ └── published-packages.json │ │ │ │ │ ├── invalid-schema │ │ │ │ │ │ └── published-packages.json │ │ │ │ │ └── valid │ │ │ │ │ └── published-packages.json │ │ │ │ └── setup.ts │ │ │ └── tsconfig.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ └── typescript-config │ ├── CHANGELOG.md │ ├── package.json │ ├── tools.json │ ├── workers-lib.json │ └── workers.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── server.json ├── tsconfig.json ├── turbo.json └── vitest.workspace.ts ``` # Files -------------------------------------------------------------------------------- /apps/ai-gateway/src/ai-gateway.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { getProps } from '@repo/mcp-common/src/get-props' 12 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 13 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 14 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 15 | 16 | import { MetricsTracker } from '../../../packages/mcp-observability/src' 17 | import { registerAIGatewayTools } from './tools/ai-gateway.tools' 18 | 19 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 20 | import type { Env } from './ai-gateway.context' 21 | 22 | const env = getEnv<Env>() 23 | 24 | export { UserDetails } 25 | 26 | const metrics = new MetricsTracker(env.MCP_METRICS, { 27 | name: env.MCP_SERVER_NAME, 28 | version: env.MCP_SERVER_VERSION, 29 | }) 30 | 31 | // Context from the auth process, encrypted & stored in the auth token 32 | // and provided to the DurableMCP as this.props 33 | type Props = AuthProps 34 | type State = { activeAccountId: string | null } 35 | 36 | export class AIGatewayMCP extends McpAgent<Env, State, Props> { 37 | _server: CloudflareMCPServer | undefined 38 | set server(server: CloudflareMCPServer) { 39 | this._server = server 40 | } 41 | get server(): CloudflareMCPServer { 42 | if (!this._server) { 43 | throw new Error('Tried to access server before it was initialized') 44 | } 45 | 46 | return this._server 47 | } 48 | 49 | constructor(ctx: DurableObjectState, env: Env) { 50 | super(ctx, env) 51 | } 52 | 53 | async init() { 54 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 55 | const props = getProps(this) 56 | const userId = props.type === 'user_token' ? props.user.id : undefined 57 | 58 | this.server = new CloudflareMCPServer({ 59 | userId, 60 | wae: this.env.MCP_METRICS, 61 | serverInfo: { 62 | name: this.env.MCP_SERVER_NAME, 63 | version: this.env.MCP_SERVER_VERSION, 64 | }, 65 | }) 66 | 67 | registerAccountTools(this) 68 | 69 | // Register Cloudflare Log Push tools 70 | registerAIGatewayTools(this) 71 | } 72 | 73 | async getActiveAccountId() { 74 | try { 75 | const props = getProps(this) 76 | // account tokens are scoped to one account 77 | if (props.type === 'account_token') { 78 | return props.account.id 79 | } 80 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 81 | // we do this so we can persist activeAccountId across sessions 82 | const userDetails = getUserDetails(env, props.user.id) 83 | return await userDetails.getActiveAccountId() 84 | } catch (e) { 85 | this.server.recordError(e) 86 | return null 87 | } 88 | } 89 | 90 | async setActiveAccountId(accountId: string) { 91 | try { 92 | const props = getProps(this) 93 | // account tokens are scoped to one account 94 | if (props.type === 'account_token') { 95 | return 96 | } 97 | const userDetails = getUserDetails(env, props.user.id) 98 | await userDetails.setActiveAccountId(accountId) 99 | } catch (e) { 100 | this.server.recordError(e) 101 | } 102 | } 103 | } 104 | 105 | const AIGatewayScopes = { 106 | ...RequiredScopes, 107 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 108 | 'aig:read': 'Grants read level access to AI Gateway.', 109 | } as const 110 | 111 | export default { 112 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 113 | if (await isApiTokenRequest(req, env)) { 114 | return await handleApiTokenMode(AIGatewayMCP, req, env, ctx) 115 | } 116 | 117 | return new OAuthProvider({ 118 | apiHandlers: { 119 | '/mcp': AIGatewayMCP.serve('/mcp'), 120 | '/sse': AIGatewayMCP.serveSSE('/sse'), 121 | }, 122 | // @ts-ignore 123 | defaultHandler: createAuthHandlers({ scopes: AIGatewayScopes, metrics }), 124 | authorizeEndpoint: '/oauth/authorize', 125 | tokenEndpoint: '/token', 126 | tokenExchangeCallback: (options) => 127 | handleTokenExchangeCallback( 128 | options, 129 | env.CLOUDFLARE_CLIENT_ID, 130 | env.CLOUDFLARE_CLIENT_SECRET 131 | ), 132 | // Cloudflare access token TTL 133 | accessTokenTTL: 3600, 134 | clientRegistrationEndpoint: '/register', 135 | }).fetch(req, env, ctx) 136 | }, 137 | } 138 | ``` -------------------------------------------------------------------------------- /apps/auditlogs/src/auditlogs.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { getProps } from '@repo/mcp-common/src/get-props' 12 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 13 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 14 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 15 | 16 | import { MetricsTracker } from '../../../packages/mcp-observability/src' 17 | import { registerAuditLogTools } from './tools/auditlogs.tools' 18 | 19 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 20 | import type { Env } from './auditlogs.context' 21 | 22 | const env = getEnv<Env>() 23 | 24 | export { UserDetails } 25 | 26 | const metrics = new MetricsTracker(env.MCP_METRICS, { 27 | name: env.MCP_SERVER_NAME, 28 | version: env.MCP_SERVER_VERSION, 29 | }) 30 | 31 | // Context from the auth process, encrypted & stored in the auth token 32 | // and provided to the DurableMCP as this.props 33 | type Props = AuthProps 34 | 35 | export type State = { activeAccountId: string | null } 36 | 37 | export class AuditlogMCP extends McpAgent<Env, State, Props> { 38 | _server: CloudflareMCPServer | undefined 39 | set server(server: CloudflareMCPServer) { 40 | this._server = server 41 | } 42 | get server(): CloudflareMCPServer { 43 | if (!this._server) { 44 | throw new Error('Tried to access server before it was initialized') 45 | } 46 | 47 | return this._server 48 | } 49 | 50 | constructor(ctx: DurableObjectState, env: Env) { 51 | super(ctx, env) 52 | } 53 | 54 | async init() { 55 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 56 | const props = getProps(this) 57 | const userId = props.type === 'user_token' ? props.user.id : undefined 58 | 59 | this.server = new CloudflareMCPServer({ 60 | userId, 61 | wae: this.env.MCP_METRICS, 62 | serverInfo: { 63 | name: this.env.MCP_SERVER_NAME, 64 | version: this.env.MCP_SERVER_VERSION, 65 | }, 66 | }) 67 | registerAccountTools(this) 68 | 69 | // Register Cloudflare Audit Log tools 70 | registerAuditLogTools(this) 71 | } 72 | 73 | async getActiveAccountId() { 74 | try { 75 | const props = getProps(this) 76 | // account tokens are scoped to one account 77 | if (props.type === 'account_token') { 78 | return props.account.id 79 | } 80 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 81 | // we do this so we can persist activeAccountId across sessions 82 | const userDetails = getUserDetails(env, props.user.id) 83 | return await userDetails.getActiveAccountId() 84 | } catch (e) { 85 | this.server.recordError(e) 86 | return null 87 | } 88 | } 89 | 90 | async setActiveAccountId(accountId: string) { 91 | try { 92 | const props = getProps(this) 93 | // account tokens are scoped to one account 94 | if (props.type === 'account_token') { 95 | return 96 | } 97 | const userDetails = getUserDetails(env, props.user.id) 98 | await userDetails.setActiveAccountId(accountId) 99 | } catch (e) { 100 | this.server.recordError(e) 101 | } 102 | } 103 | } 104 | 105 | const AuditlogScopes = { 106 | ...RequiredScopes, 107 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 108 | 'auditlogs:read': 'See your resource configuration changes.', 109 | } as const 110 | 111 | export default { 112 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 113 | if (await isApiTokenRequest(req, env)) { 114 | return await handleApiTokenMode(AuditlogMCP, req, env, ctx) 115 | } 116 | 117 | return new OAuthProvider({ 118 | apiHandlers: { 119 | '/mcp': AuditlogMCP.serve('/mcp'), 120 | '/sse': AuditlogMCP.serveSSE('/sse'), 121 | }, 122 | // @ts-ignore 123 | defaultHandler: createAuthHandlers({ scopes: AuditlogScopes, metrics }), 124 | authorizeEndpoint: '/oauth/authorize', 125 | tokenEndpoint: '/token', 126 | tokenExchangeCallback: (options) => 127 | handleTokenExchangeCallback( 128 | options, 129 | env.CLOUDFLARE_CLIENT_ID, 130 | env.CLOUDFLARE_CLIENT_SECRET 131 | ), 132 | // Cloudflare access token TTL 133 | accessTokenTTL: 3600, 134 | clientRegistrationEndpoint: '/register', 135 | }).fetch(req, env, ctx) 136 | }, 137 | } 138 | ``` -------------------------------------------------------------------------------- /apps/dex-analysis/src/dex-analysis.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { getProps } from '@repo/mcp-common/src/get-props' 12 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 13 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 14 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 15 | import { MetricsTracker } from '@repo/mcp-observability' 16 | 17 | import { registerDEXTools } from './tools/dex-analysis.tools' 18 | 19 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 20 | import type { Env } from './dex-analysis.context' 21 | 22 | export { UserDetails } 23 | export { WarpDiagReader } from './warp_diag_reader' 24 | 25 | const env = getEnv<Env>() 26 | 27 | const metrics = new MetricsTracker(env.MCP_METRICS, { 28 | name: env.MCP_SERVER_NAME, 29 | version: env.MCP_SERVER_VERSION, 30 | }) 31 | 32 | // Context from the auth process, encrypted & stored in the auth token 33 | // and provided to the DurableMCP as this.props 34 | type Props = AuthProps 35 | 36 | type State = { activeAccountId: string | null } 37 | 38 | export class CloudflareDEXMCP extends McpAgent<Env, State, Props> { 39 | _server: CloudflareMCPServer | undefined 40 | set server(server: CloudflareMCPServer) { 41 | this._server = server 42 | } 43 | 44 | get server(): CloudflareMCPServer { 45 | if (!this._server) { 46 | throw new Error('Tried to access server before it was initialized') 47 | } 48 | 49 | return this._server 50 | } 51 | 52 | constructor(ctx: DurableObjectState, env: Env) { 53 | super(ctx, env) 54 | } 55 | 56 | async init() { 57 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 58 | const props = getProps(this) 59 | const userId = props.type === 'user_token' ? props.user.id : undefined 60 | 61 | this.server = new CloudflareMCPServer({ 62 | userId, 63 | wae: this.env.MCP_METRICS, 64 | serverInfo: { 65 | name: this.env.MCP_SERVER_NAME, 66 | version: this.env.MCP_SERVER_VERSION, 67 | }, 68 | }) 69 | 70 | registerAccountTools(this) 71 | registerDEXTools(this) 72 | } 73 | 74 | async getActiveAccountId() { 75 | try { 76 | const props = getProps(this) 77 | // account tokens are scoped to one account 78 | if (props.type === 'account_token') { 79 | return props.account.id 80 | } 81 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 82 | // we do this so we can persist activeAccountId across sessions 83 | const userDetails = getUserDetails(env, props.user.id) 84 | return await userDetails.getActiveAccountId() 85 | } catch (e) { 86 | this.server.recordError(e) 87 | return null 88 | } 89 | } 90 | 91 | async setActiveAccountId(accountId: string) { 92 | try { 93 | const props = getProps(this) 94 | // account tokens are scoped to one account 95 | if (props.type === 'account_token') { 96 | return 97 | } 98 | const userDetails = getUserDetails(env, props.user.id) 99 | await userDetails.setActiveAccountId(accountId) 100 | } catch (e) { 101 | this.server.recordError(e) 102 | } 103 | } 104 | } 105 | 106 | const DexScopes = { 107 | ...RequiredScopes, 108 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 109 | 'dex:write': 110 | 'Grants write level access to DEX resources like tests, fleet status, and remote captures.', 111 | } as const 112 | 113 | export default { 114 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 115 | if (await isApiTokenRequest(req, env)) { 116 | return await handleApiTokenMode(CloudflareDEXMCP, req, env, ctx) 117 | } 118 | 119 | return new OAuthProvider({ 120 | apiHandlers: { 121 | '/mcp': CloudflareDEXMCP.serve('/mcp'), 122 | '/sse': CloudflareDEXMCP.serveSSE('/sse'), 123 | }, 124 | // @ts-ignore 125 | defaultHandler: createAuthHandlers({ scopes: DexScopes, metrics }), 126 | authorizeEndpoint: '/oauth/authorize', 127 | tokenEndpoint: '/token', 128 | tokenExchangeCallback: (options) => 129 | handleTokenExchangeCallback( 130 | options, 131 | env.CLOUDFLARE_CLIENT_ID, 132 | env.CLOUDFLARE_CLIENT_SECRET 133 | ), 134 | // Cloudflare access token TTL 135 | accessTokenTTL: 3600, 136 | clientRegistrationEndpoint: '/register', 137 | }).fetch(req, env, ctx) 138 | }, 139 | } 140 | ``` -------------------------------------------------------------------------------- /packages/mcp-common/src/sentry.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError } from 'cloudflare' 2 | import { Toucan, zodErrorsIntegration } from 'toucan-js' 3 | 4 | import { McpError } from './mcp-error' 5 | 6 | import type { BaseTransportOptions, Client, ClientOptions, Event, EventHint } from '@sentry/types' 7 | import type { Context, Next } from 'hono' 8 | import type { Context as SentryContext } from 'toucan-js/dist/types' 9 | import type { MCPEnvironment } from './config' 10 | 11 | function is5xxError(status: number): boolean { 12 | return status >= 500 && status <= 599 13 | } 14 | 15 | export class SentryClient { 16 | private sentry: Toucan 17 | constructor(sentry: Toucan) { 18 | this.sentry = sentry 19 | } 20 | 21 | public recordError(e: unknown) { 22 | if (this.sentry) { 23 | // ignore errors from McpError and APIError (cloudflare) that have reportToSentry = false, or aren't 5xx errors 24 | if (e instanceof McpError) { 25 | if (e.reportToSentry === false) { 26 | return 27 | } 28 | } else if (e instanceof APIError) { 29 | if (!is5xxError(e.status)) { 30 | return 31 | } 32 | } 33 | this.sentry.captureException(e) 34 | } 35 | } 36 | 37 | public setUser(userId: string) { 38 | this.sentry.setUser({ ...this.sentry.getUser(), user_id: userId }) 39 | } 40 | } 41 | 42 | interface BaseBindings { 43 | ENVIRONMENT: MCPEnvironment 44 | GIT_HASH: string 45 | SENTRY_DSN: string 46 | SENTRY_ACCESS_CLIENT_ID: string 47 | SENTRY_ACCESS_CLIENT_SECRET: string 48 | } 49 | 50 | export interface BaseHonoContext { 51 | Bindings: BaseBindings 52 | Variables: { 53 | sentry?: SentryClient 54 | } 55 | } 56 | 57 | export function initSentry<T extends BaseBindings>( 58 | env: T, 59 | ctx: SentryContext, 60 | req?: Request<unknown, CfProperties> 61 | ): SentryClient { 62 | const sentry = new Toucan({ 63 | dsn: env.SENTRY_DSN, 64 | request: req, 65 | environment: env.ENVIRONMENT, 66 | context: ctx, 67 | release: env.GIT_HASH, 68 | requestDataOptions: { 69 | allowedHeaders: [ 70 | 'user-agent', 71 | 'cf-challenge', 72 | 'accept-encoding', 73 | 'accept-language', 74 | 'cf-ray', 75 | 'content-length', 76 | 'content-type', 77 | 'host', 78 | ], 79 | // Allow ONLY the “scope” param in order to avoid recording jwt, code, state and any other callback params 80 | allowedSearchParams: /^scope$/, 81 | }, 82 | integrations: [ 83 | zodErrorsIntegration({ saveAttachments: true }), 84 | { 85 | name: 'mcp-api-errors', 86 | processEvent( 87 | event: Event, 88 | _hint: EventHint, 89 | _client: Client<ClientOptions<BaseTransportOptions>> 90 | ): Event { 91 | const processedEvent = applyMcpErrorsToEvent(event) 92 | return processedEvent 93 | }, 94 | }, 95 | ], 96 | transportOptions: { 97 | headers: { 98 | 'CF-Access-Client-ID': env.SENTRY_ACCESS_CLIENT_ID, 99 | 'CF-Access-Client-Secret': env.SENTRY_ACCESS_CLIENT_SECRET, 100 | }, 101 | }, 102 | }) 103 | return new SentryClient(sentry) 104 | } 105 | 106 | export function initSentryWithUser<T extends BaseBindings>( 107 | env: T, 108 | ctx: SentryContext, 109 | userId: string, 110 | req?: Request<unknown, CfProperties> 111 | ): SentryClient { 112 | const sentryClient = initSentry(env, ctx, req) 113 | sentryClient.setUser(userId) 114 | return sentryClient 115 | } 116 | 117 | export async function useSentry<T extends BaseHonoContext>( 118 | c: Context<T>, 119 | next: Next 120 | ): Promise<void> { 121 | c.set('sentry', initSentry(c.env, c.executionCtx, c.req.raw)) 122 | await next() 123 | } 124 | 125 | export function setSentryRequestHeaders(sentry: Toucan, req: Request<unknown, CfProperties>) { 126 | const colo: string = req.cf && typeof req.cf.colo === 'string' ? req.cf.colo : 'UNKNOWN' 127 | sentry.setTag('colo', colo) 128 | 129 | const ip_address = req.headers.get('cf-connecting-ip') ?? '' 130 | const userAgent = req.headers.get('user-agent') ?? '' 131 | sentry.setUser({ 132 | ...sentry.getUser(), 133 | ip_address, 134 | userAgent, 135 | colo, 136 | }) 137 | } 138 | 139 | function applyMcpErrorsToEvent(event: Event): Event { 140 | if (event.exception === undefined || event.exception.values === undefined) { 141 | return event 142 | } 143 | 144 | if (event.exception instanceof McpError) { 145 | try { 146 | return { 147 | ...event, 148 | extra: { 149 | ...event.extra, 150 | statusCode: event.exception.code, 151 | internalMessage: event.exception.internalMessage, 152 | }, 153 | } 154 | } catch (e) { 155 | // Hopefully we never throw errors here, but record it 156 | // with the event just in case. 157 | return { 158 | ...event, 159 | extra: { 160 | ...event.extra, 161 | 'McpError sentry integration parse error': { 162 | message: `an exception was thrown while processing McpError within applyMcpErrorsToEvent()`, 163 | error: e instanceof Error ? `${e.name}: ${e.cause}\n${e.stack}` : 'unknown', 164 | }, 165 | }, 166 | } 167 | } 168 | } 169 | 170 | return event 171 | } 172 | ``` -------------------------------------------------------------------------------- /packages/mcp-common/src/tools/docs-vectorize.tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod' 2 | 3 | import type { CloudflareMcpAgentNoAccount } from '../types/cloudflare-mcp-agent.types' 4 | 5 | interface RequiredEnv { 6 | AI: Ai 7 | VECTORIZE: VectorizeIndex 8 | } 9 | 10 | // Always return 10 results for simplicity, don't make it configurable 11 | const TOP_K = 10 12 | 13 | /** 14 | * Registers the docs search tool with the MCP server 15 | * @param agent The MCP server instance 16 | */ 17 | export function registerDocsTools(agent: CloudflareMcpAgentNoAccount, env: RequiredEnv) { 18 | agent.server.tool( 19 | 'search_cloudflare_documentation', 20 | `Search the Cloudflare documentation. 21 | 22 | This tool should be used to answer any question about Cloudflare products or features, including: 23 | - Workers, Pages, R2, Images, Stream, D1, Durable Objects, KV, Workflows, Hyperdrive, Queues 24 | - AutoRAG, Workers AI, Vectorize, AI Gateway, Browser Rendering 25 | - Zero Trust, Access, Tunnel, Gateway, Browser Isolation, WARP, DDOS, Magic Transit, Magic WAN 26 | - CDN, Cache, DNS, Zaraz, Argo, Rulesets, Terraform, Account and Billing 27 | 28 | Results are returned as semantically similar chunks to the query. 29 | `, 30 | { 31 | query: z.string(), 32 | }, 33 | { 34 | title: 'Search Cloudflare docs', 35 | annotations: { 36 | readOnlyHint: true, 37 | }, 38 | }, 39 | async ({ query }) => { 40 | const results = await queryVectorize(env.AI, env.VECTORIZE, query, TOP_K) 41 | const resultsAsXml = results 42 | .map((result) => { 43 | return `<result> 44 | <url>${result.url}</url> 45 | <title>${result.title}</title> 46 | <text> 47 | ${result.text} 48 | </text> 49 | </result>` 50 | }) 51 | .join('\n') 52 | return { 53 | content: [{ type: 'text', text: resultsAsXml }], 54 | } 55 | } 56 | ) 57 | 58 | // Note: this is a tool instead of a prompt because 59 | // prompt support is much less common than tools. 60 | agent.server.tool( 61 | 'migrate_pages_to_workers_guide', 62 | `ALWAYS read this guide before migrating Pages projects to Workers.`, 63 | {}, 64 | { 65 | title: 'Get Pages migration guide', 66 | annotations: { 67 | readOnlyHint: true, 68 | }, 69 | }, 70 | async () => { 71 | const res = await fetch( 72 | 'https://developers.cloudflare.com/workers/prompts/pages-to-workers.txt', 73 | { 74 | cf: { cacheEverything: true, cacheTtl: 3600 }, 75 | } 76 | ) 77 | 78 | if (!res.ok) { 79 | return { 80 | content: [{ type: 'text', text: 'Error: Failed to fetch guide. Please try again.' }], 81 | } 82 | } 83 | 84 | return { 85 | content: [ 86 | { 87 | type: 'text', 88 | text: await res.text(), 89 | }, 90 | ], 91 | } 92 | } 93 | ) 94 | } 95 | 96 | async function queryVectorize(ai: Ai, vectorizeIndex: VectorizeIndex, query: string, topK: number) { 97 | // Recommendation from: https://ai.google.dev/gemma/docs/embeddinggemma/model_card#prompt_instructions 98 | const [queryEmbedding] = await getEmbeddings(ai, ['task: search result | query: ' + query]) 99 | 100 | const { matches } = await vectorizeIndex.query(queryEmbedding, { 101 | topK, 102 | returnMetadata: 'all', 103 | returnValues: false, 104 | }) 105 | 106 | return matches.map((match, _i) => ({ 107 | similarity: Math.min(match.score, 1), 108 | id: match.id, 109 | url: sourceToUrl(String(match.metadata?.filePath ?? '')), 110 | title: String(match.metadata?.title ?? ''), 111 | text: String(match.metadata?.text ?? ''), 112 | })) 113 | } 114 | 115 | const TOP_DIR = 'src/content/docs' 116 | function sourceToUrl(path: string) { 117 | const prefix = `${TOP_DIR}/` 118 | return ( 119 | 'https://developers.cloudflare.com/' + 120 | (path.startsWith(prefix) ? path.slice(prefix.length) : path) 121 | .replace(/index\.mdx$/, '') 122 | .replace(/\.mdx$/, '') 123 | ) 124 | } 125 | 126 | async function getEmbeddings(ai: Ai, strings: string[]): Promise<number[][]> { 127 | const response = await doWithRetries(() => 128 | // @ts-expect-error embeddinggemma not in types yet 129 | ai.run('@cf/google/embeddinggemma-300m', { 130 | text: strings, 131 | }) 132 | ) 133 | 134 | // @ts-expect-error embeddinggemma not in types yet 135 | return response.data 136 | } 137 | 138 | /** 139 | * @template T 140 | * @param {() => Promise<T>} action 141 | */ 142 | async function doWithRetries<T>(action: () => Promise<T>) { 143 | const NUM_RETRIES = 10 144 | const INIT_RETRY_MS = 50 145 | for (let i = 0; i <= NUM_RETRIES; i++) { 146 | try { 147 | return await action() 148 | } catch (e) { 149 | // TODO: distinguish between user errors (4xx) and system errors (5xx) 150 | console.error(e) 151 | if (i === NUM_RETRIES) { 152 | throw e 153 | } 154 | // Exponential backoff with full jitter 155 | await scheduler.wait(Math.random() * INIT_RETRY_MS * Math.pow(2, i)) 156 | } 157 | } 158 | // Should never reach here – last loop iteration should return 159 | throw new Error('An unknown error occurred') 160 | } 161 | ``` -------------------------------------------------------------------------------- /apps/logpush/src/logpush.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { getProps } from '@repo/mcp-common/src/get-props' 12 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 13 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 14 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 15 | 16 | import { MetricsTracker } from '../../../packages/mcp-observability/src' 17 | import { registerLogsTools } from './tools/logpush.tools' 18 | 19 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 20 | import type { Env } from './logpush.context' 21 | 22 | const env = getEnv<Env>() 23 | 24 | export { UserDetails } 25 | 26 | const metrics = new MetricsTracker(env.MCP_METRICS, { 27 | name: env.MCP_SERVER_NAME, 28 | version: env.MCP_SERVER_VERSION, 29 | }) 30 | 31 | // Context from the auth process, encrypted & stored in the auth token 32 | // and provided to the DurableMCP as this.props 33 | type Props = AuthProps 34 | type State = { activeAccountId: string | null } 35 | 36 | export class LogsMCP extends McpAgent<Env, State, Props> { 37 | _server: CloudflareMCPServer | undefined 38 | set server(server: CloudflareMCPServer) { 39 | this._server = server 40 | } 41 | get server(): CloudflareMCPServer { 42 | if (!this._server) { 43 | throw new Error('Tried to access server before it was initialized') 44 | } 45 | 46 | return this._server 47 | } 48 | 49 | constructor(ctx: DurableObjectState, env: Env) { 50 | super(ctx, env) 51 | } 52 | 53 | async init() { 54 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 55 | const props = getProps(this) 56 | const userId = props.type === 'user_token' ? props.user.id : undefined 57 | 58 | this.server = new CloudflareMCPServer({ 59 | userId, 60 | wae: this.env.MCP_METRICS, 61 | serverInfo: { 62 | name: this.env.MCP_SERVER_NAME, 63 | version: this.env.MCP_SERVER_VERSION, 64 | }, 65 | }) 66 | 67 | registerAccountTools(this) 68 | 69 | // Register Cloudflare Log Push tools 70 | registerLogsTools(this) 71 | } 72 | 73 | async getActiveAccountId() { 74 | try { 75 | const props = getProps(this) 76 | // account tokens are scoped to one account 77 | if (props.type === 'account_token') { 78 | return props.account.id 79 | } 80 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 81 | // we do this so we can persist activeAccountId across sessions 82 | const userDetails = getUserDetails(env, props.user.id) 83 | return await userDetails.getActiveAccountId() 84 | } catch (e) { 85 | this.server.recordError(e) 86 | return null 87 | } 88 | } 89 | 90 | async setActiveAccountId(accountId: string) { 91 | try { 92 | const props = getProps(this) 93 | // account tokens are scoped to one account 94 | if (props.type === 'account_token') { 95 | return 96 | } 97 | const userDetails = getUserDetails(env, props.user.id) 98 | await userDetails.setActiveAccountId(accountId) 99 | } catch (e) { 100 | this.server.recordError(e) 101 | } 102 | } 103 | } 104 | 105 | const LogPushScopes = { 106 | ...RequiredScopes, 107 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 108 | 'logpush:write': 109 | 'Grants read and write access to Logpull and Logpush, and read access to Instant Logs. Note that all Logpush API operations require Logs: Write permission because Logpush jobs contain sensitive information.', 110 | } as const 111 | 112 | export default { 113 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 114 | if (await isApiTokenRequest(req, env)) { 115 | return await handleApiTokenMode(LogsMCP, req, env, ctx) 116 | } 117 | 118 | return new OAuthProvider({ 119 | apiHandlers: { 120 | '/mcp': LogsMCP.serve('/mcp'), 121 | '/sse': LogsMCP.serveSSE('/sse'), 122 | }, 123 | // @ts-ignore 124 | defaultHandler: createAuthHandlers({ scopes: LogPushScopes, metrics }), 125 | authorizeEndpoint: '/oauth/authorize', 126 | tokenEndpoint: '/token', 127 | tokenExchangeCallback: (options) => 128 | handleTokenExchangeCallback( 129 | options, 130 | env.CLOUDFLARE_CLIENT_ID, 131 | env.CLOUDFLARE_CLIENT_SECRET 132 | ), 133 | // Cloudflare access token TTL 134 | accessTokenTTL: 3600, 135 | clientRegistrationEndpoint: '/register', 136 | }).fetch(req, env, ctx) 137 | }, 138 | } 139 | ``` -------------------------------------------------------------------------------- /apps/workers-bindings/evals/kv_namespaces.eval.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { expect } from 'vitest' 2 | import { describeEval } from 'vitest-evals' 3 | 4 | import { runTask } from '@repo/eval-tools/src/runTask' 5 | import { checkFactuality } from '@repo/eval-tools/src/scorers' 6 | import { eachModel } from '@repo/eval-tools/src/test-models' 7 | import { KV_NAMESPACE_TOOLS } from '@repo/mcp-common/src/tools/kv_namespace.tools' 8 | 9 | import { initializeClient } from './utils' // Assuming utils.ts will exist here 10 | 11 | eachModel('$modelName', ({ model }) => { 12 | describeEval('Create Cloudflare KV Namespace', { 13 | data: async () => [ 14 | { 15 | input: 'Create a new Cloudflare KV Namespace called "my-test-namespace".', 16 | expected: `The ${KV_NAMESPACE_TOOLS.kv_namespace_create} tool should be called to create a new kv namespace.`, 17 | }, 18 | ], 19 | task: async (input: string) => { 20 | const client = await initializeClient(/* Pass necessary mocks/config */) 21 | const { promptOutput, toolCalls } = await runTask(client, model, input) 22 | const toolCall = toolCalls.find( 23 | (call) => call.toolName === KV_NAMESPACE_TOOLS.kv_namespace_create 24 | ) 25 | expect(toolCall, 'Tool kv_namespace_create was not called').toBeDefined() 26 | 27 | return promptOutput 28 | }, 29 | scorers: [checkFactuality], 30 | threshold: 1, 31 | timeout: 60000, // 60 seconds 32 | }) 33 | describeEval('List Cloudflare KV Namespaces', { 34 | data: async () => [ 35 | { 36 | input: 'List all my Cloudflare KV Namespaces.', 37 | expected: `The ${KV_NAMESPACE_TOOLS.kv_namespaces_list} tool should be called to retrieve the list of kv namespaces. There should be at least one kv namespace in the list.`, 38 | }, 39 | ], 40 | task: async (input: string) => { 41 | const client = await initializeClient(/* Pass necessary mocks/config */) 42 | const { promptOutput, toolCalls } = await runTask(client, model, input) 43 | const toolCall = toolCalls.find( 44 | (call) => call.toolName === KV_NAMESPACE_TOOLS.kv_namespaces_list 45 | ) 46 | expect(toolCall, 'Tool kv_namespaces_list was not called').toBeDefined() 47 | 48 | return promptOutput 49 | }, 50 | scorers: [checkFactuality], 51 | threshold: 1, 52 | timeout: 60000, // 60 seconds 53 | }) 54 | describeEval('Rename Cloudflare KV Namespace', { 55 | data: async () => [ 56 | { 57 | input: 'Rename my Cloudflare KV Namespace with ID 1234 to "my-new-test-namespace".', 58 | expected: `The ${KV_NAMESPACE_TOOLS.kv_namespace_update} tool should be called to rename the kv namespace.`, 59 | }, 60 | ], 61 | task: async (input: string) => { 62 | const client = await initializeClient(/* Pass necessary mocks/config */) 63 | const { promptOutput, toolCalls } = await runTask(client, model, input) 64 | const toolCall = toolCalls.find( 65 | (call) => call.toolName === KV_NAMESPACE_TOOLS.kv_namespace_update 66 | ) 67 | expect(toolCall, 'Tool kv_namespace_update was not called').toBeDefined() 68 | 69 | return promptOutput 70 | }, 71 | scorers: [checkFactuality], 72 | threshold: 1, 73 | timeout: 60000, // 60 seconds 74 | }) 75 | describeEval('Get Cloudflare KV Namespace Details', { 76 | data: async () => [ 77 | { 78 | input: 'Get details of my Cloudflare KV Namespace with ID 1234.', 79 | expected: `The ${KV_NAMESPACE_TOOLS.kv_namespace_get} tool should be called to retrieve the details of the kv namespace.`, 80 | }, 81 | ], 82 | task: async (input: string) => { 83 | const client = await initializeClient(/* Pass necessary mocks/config */) 84 | const { promptOutput, toolCalls } = await runTask(client, model, input) 85 | const toolCall = toolCalls.find( 86 | (call) => call.toolName === KV_NAMESPACE_TOOLS.kv_namespace_get 87 | ) 88 | expect(toolCall, 'Tool kv_namespace_get was not called').toBeDefined() 89 | 90 | return promptOutput 91 | }, 92 | scorers: [checkFactuality], 93 | threshold: 1, 94 | timeout: 60000, // 60 seconds 95 | }) 96 | describeEval('Delete Cloudflare KV Namespace', { 97 | data: async () => [ 98 | { 99 | input: 'Delete the kv namespace with ID 1234.', 100 | expected: `The ${KV_NAMESPACE_TOOLS.kv_namespace_delete} tool should be called to delete the kv namespace.`, 101 | }, 102 | ], 103 | task: async (input: string) => { 104 | const client = await initializeClient(/* Pass necessary mocks/config */) 105 | const { promptOutput, toolCalls } = await runTask(client, model, input) 106 | const toolCall = toolCalls.find( 107 | (call) => call.toolName === KV_NAMESPACE_TOOLS.kv_namespace_delete 108 | ) 109 | expect(toolCall, 'Tool kv_namespace_delete was not called').toBeDefined() 110 | 111 | return promptOutput 112 | }, 113 | scorers: [checkFactuality], 114 | threshold: 1, 115 | timeout: 60000, // 60 seconds 116 | }) 117 | }) 118 | ``` -------------------------------------------------------------------------------- /apps/radar/src/radar.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { getProps } from '@repo/mcp-common/src/get-props' 12 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 13 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 14 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 15 | import { MetricsTracker } from '@repo/mcp-observability' 16 | 17 | import { BASE_INSTRUCTIONS } from './radar.context' 18 | import { registerRadarTools } from './tools/radar.tools' 19 | import { registerUrlScannerTools } from './tools/url-scanner.tools' 20 | 21 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 22 | import type { Env } from './radar.context' 23 | 24 | const env = getEnv<Env>() 25 | 26 | export { UserDetails } 27 | 28 | const metrics = new MetricsTracker(env.MCP_METRICS, { 29 | name: env.MCP_SERVER_NAME, 30 | version: env.MCP_SERVER_VERSION, 31 | }) 32 | 33 | // Context from the auth process, encrypted & stored in the auth token 34 | // and provided to the DurableMCP as this.props 35 | type Props = AuthProps 36 | type State = { activeAccountId: string | null } 37 | 38 | export class RadarMCP extends McpAgent<Env, State, Props> { 39 | _server: CloudflareMCPServer | undefined 40 | set server(server: CloudflareMCPServer) { 41 | this._server = server 42 | } 43 | get server(): CloudflareMCPServer { 44 | if (!this._server) { 45 | throw new Error('Tried to access server before it was initialized') 46 | } 47 | 48 | return this._server 49 | } 50 | 51 | constructor(ctx: DurableObjectState, env: Env) { 52 | super(ctx, env) 53 | } 54 | 55 | async init() { 56 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 57 | const props = getProps(this) 58 | const userId = props.type === 'user_token' ? props.user.id : undefined 59 | 60 | this.server = new CloudflareMCPServer({ 61 | userId, 62 | wae: this.env.MCP_METRICS, 63 | serverInfo: { 64 | name: this.env.MCP_SERVER_NAME, 65 | version: this.env.MCP_SERVER_VERSION, 66 | }, 67 | options: { instructions: BASE_INSTRUCTIONS }, 68 | }) 69 | 70 | registerAccountTools(this) 71 | registerRadarTools(this) 72 | registerUrlScannerTools(this) 73 | } 74 | 75 | async getActiveAccountId() { 76 | try { 77 | const props = getProps(this) 78 | // account tokens are scoped to one account 79 | if (props.type === 'account_token') { 80 | return props.account.id 81 | } 82 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 83 | // we do this so we can persist activeAccountId across sessions 84 | const userDetails = getUserDetails(env, props.user.id) 85 | return await userDetails.getActiveAccountId() 86 | } catch (e) { 87 | this.server.recordError(e) 88 | return null 89 | } 90 | } 91 | 92 | async setActiveAccountId(accountId: string) { 93 | try { 94 | const props = getProps(this) 95 | // account tokens are scoped to one account 96 | if (props.type === 'account_token') { 97 | return 98 | } 99 | const userDetails = getUserDetails(env, props.user.id) 100 | await userDetails.setActiveAccountId(accountId) 101 | } catch (e) { 102 | this.server.recordError(e) 103 | } 104 | } 105 | } 106 | 107 | const RadarScopes = { 108 | ...RequiredScopes, 109 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 110 | 'radar:read': 'Grants access to read Cloudflare Radar data.', 111 | 'url_scanner:write': 'Grants write level access to URL Scanner', 112 | } as const 113 | 114 | export default { 115 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 116 | if (await isApiTokenRequest(req, env)) { 117 | return await handleApiTokenMode(RadarMCP, req, env, ctx) 118 | } 119 | 120 | return new OAuthProvider({ 121 | apiHandlers: { 122 | '/mcp': RadarMCP.serve('/mcp'), 123 | '/sse': RadarMCP.serveSSE('/sse'), 124 | }, 125 | // @ts-ignore 126 | defaultHandler: createAuthHandlers({ scopes: RadarScopes, metrics }), 127 | authorizeEndpoint: '/oauth/authorize', 128 | tokenEndpoint: '/token', 129 | tokenExchangeCallback: (options) => 130 | handleTokenExchangeCallback( 131 | options, 132 | env.CLOUDFLARE_CLIENT_ID, 133 | env.CLOUDFLARE_CLIENT_SECRET 134 | ), 135 | // Cloudflare access token TTL 136 | accessTokenTTL: 3600, 137 | clientRegistrationEndpoint: '/register', 138 | }).fetch(req, env, ctx) 139 | }, 140 | } 141 | ``` -------------------------------------------------------------------------------- /packages/mcp-observability/src/analytics-engine.ts: -------------------------------------------------------------------------------- ```typescript 1 | export type MetricsBindings = { 2 | MCP_METRICS: AnalyticsEngineDataset 3 | } 4 | 5 | /** 6 | * Generic metrics event utilities 7 | * @description Wrapper for RA binding 8 | */ 9 | export class MetricsTracker { 10 | constructor( 11 | private wae: AnalyticsEngineDataset, 12 | private mcpServerInfo: { 13 | name: string 14 | version: string 15 | } 16 | ) {} 17 | 18 | logEvent(event: MetricsEvent): void { 19 | try { 20 | event.serverInfo = this.mcpServerInfo 21 | let dataPoint = event.toDataPoint() 22 | this.wae.writeDataPoint(dataPoint) 23 | } catch (e) { 24 | console.error(`Failed to log metrics event, ${e}`) 25 | } 26 | } 27 | } 28 | 29 | /** 30 | * MetricsEvent 31 | * 32 | * Each event type is stored with a different indexId and has an associated class which 33 | * maps a more ergonomic event object to a ReadyAnalyticsEvent 34 | */ 35 | export abstract class MetricsEvent { 36 | public _serverInfo: { name: string; version: string } | undefined 37 | set serverInfo(serverInfo: { name: string; version: string }) { 38 | this._serverInfo = serverInfo 39 | } 40 | 41 | get serverInfo(): { name: string; version: string } { 42 | if (!this._serverInfo) { 43 | throw new Error('Server info not set') 44 | } 45 | return this._serverInfo 46 | } 47 | 48 | /** 49 | * Output a valid AnalyticsEngineDataPoint. Use `mapBlobs` and `mapDoubles` to write well defined 50 | * analytics engine datapoints. The first and second blob entries are reserved for the MCP server name and 51 | * MCP server version. 52 | */ 53 | abstract toDataPoint(): AnalyticsEngineDataPoint 54 | 55 | mapBlobs(blobs: Blobs): Array<string | null> { 56 | if (blobs.blob1 || blobs.blob2) { 57 | throw new MetricsError( 58 | 'Failed to map blobs, blob1 and blob2 are reserved for MCP server info' 59 | ) 60 | } 61 | // add placeholder blobs, filled in by the MetricsTracker later 62 | blobs.blob1 = this.serverInfo.name 63 | blobs.blob2 = this.serverInfo.version 64 | const blobsArray = new Array(Object.keys(blobs).length) 65 | for (const [key, value] of Object.entries(blobs)) { 66 | const match = key.match(/^blob(\d+)$/) 67 | if (match === null || match.length < 2) { 68 | // we should never hit this because of the typedefinitions above, 69 | // but this error is for safety 70 | throw new MetricsError('Failed to map blobs, invalid key') 71 | } 72 | const index = parseInt(match[1], 10) 73 | if (isNaN(index)) { 74 | // we should never hit this because of the typedefinitions above, 75 | // but this esrror is for safety 76 | throw new MetricsError('Failed to map blobs, invalid index') 77 | } 78 | if (index - 1 >= blobsArray.length) { 79 | throw new MetricsError('Failed to map blobs, missing blob') 80 | } 81 | blobsArray[index - 1] = value 82 | } 83 | return blobsArray 84 | } 85 | 86 | mapDoubles(doubles: Doubles): number[] { 87 | const doublesArray = new Array(Object.keys(doubles).length) 88 | for (const [key, value] of Object.entries(doubles)) { 89 | const match = key.match(/^double(\d+)$/) 90 | if (match === null || match.length < 2) { 91 | // we should never hit this because of the typedefinitions above, 92 | // but this error is for safety 93 | throw new MetricsError(': Failed to map doubles, invalid key') 94 | } 95 | const index = parseInt(match[1], 10) 96 | if (isNaN(index)) { 97 | // we should never hit this because of the typedefinitions above, 98 | // but this error is for safety 99 | throw new MetricsError('Failed to map doubles, invalid index') 100 | } 101 | if (index - 1 >= doublesArray.length) { 102 | throw new MetricsError('Failed to map doubles, missing blob') 103 | } 104 | doublesArray[index - 1] = value 105 | } 106 | return doublesArray 107 | } 108 | } 109 | 110 | export enum MetricsEventIndexIds { 111 | AUTH_USER = 'auth_user', 112 | SESSION_START = 'session_start', 113 | TOOL_CALL = 'tool_call', 114 | CONTAINER_MANAGER = 'container_manager', 115 | } 116 | 117 | /** 118 | * Utility functions to map named blob/double objects to an array 119 | * We do this so we don't have to annotate `blob1`, `blob2`, etc in comments. 120 | * 121 | * I prefer this to just writing it in an array because it'll be easier to reference 122 | * later when we are writing ready analytics queries. 123 | * 124 | * IMO named tuples and raw arrays aren't as ergonomic to work with, but they require less of this code below 125 | */ 126 | type Range1To20 = 127 | | 1 128 | | 2 129 | | 3 130 | | 4 131 | | 5 132 | | 6 133 | | 7 134 | | 8 135 | | 9 136 | | 10 137 | | 11 138 | | 12 139 | | 13 140 | | 14 141 | | 15 142 | | 16 143 | | 17 144 | | 18 145 | | 19 146 | | 20 147 | 148 | // blob1 and blob2 are reserved for server name and version 149 | type Blobs = { 150 | [key in `blob${Range1To20}`]?: string | null 151 | } 152 | 153 | type Doubles = { 154 | [key in `double${Range1To20}`]?: number 155 | } 156 | 157 | export class MetricsError extends Error { 158 | constructor(message: string) { 159 | super(message) 160 | this.name = 'MetricsError' 161 | } 162 | } 163 | ``` -------------------------------------------------------------------------------- /apps/dns-analytics/src/dns-analytics.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { getProps } from '@repo/mcp-common/src/get-props' 12 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 13 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 14 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 15 | import { registerZoneTools } from '@repo/mcp-common/src/tools/zone.tools' 16 | 17 | import { MetricsTracker } from '../../../packages/mcp-observability/src' 18 | import { registerAnalyticTools } from './tools/dex-analytics.tools' 19 | 20 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 21 | import type { Env } from './dns-analytics.context' 22 | 23 | export { UserDetails } 24 | 25 | const env = getEnv<Env>() 26 | 27 | const metrics = new MetricsTracker(env.MCP_METRICS, { 28 | name: env.MCP_SERVER_NAME, 29 | version: env.MCP_SERVER_VERSION, 30 | }) 31 | 32 | // Context from the auth process, encrypted & stored in the auth token 33 | // and provided to the DurableMCP as this.props 34 | export type Props = AuthProps 35 | 36 | export type State = { activeAccountId: string | null } 37 | 38 | export class DNSAnalyticsMCP extends McpAgent<Env, State, Props> { 39 | _server: CloudflareMCPServer | undefined 40 | set server(server: CloudflareMCPServer) { 41 | this._server = server 42 | } 43 | 44 | get server(): CloudflareMCPServer { 45 | if (!this._server) { 46 | throw new Error('Tried to access server before it was initialized') 47 | } 48 | 49 | return this._server 50 | } 51 | 52 | constructor(ctx: DurableObjectState, env: Env) { 53 | super(ctx, env) 54 | } 55 | 56 | async init() { 57 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 58 | const props = getProps(this) 59 | const userId = props.type === 'user_token' ? props.user.id : undefined 60 | 61 | this.server = new CloudflareMCPServer({ 62 | userId, 63 | wae: this.env.MCP_METRICS, 64 | serverInfo: { 65 | name: this.env.MCP_SERVER_NAME, 66 | version: this.env.MCP_SERVER_VERSION, 67 | }, 68 | }) 69 | 70 | registerAccountTools(this) 71 | 72 | // Register Cloudflare DNS Analytics tools 73 | registerAnalyticTools(this) 74 | 75 | registerZoneTools(this) 76 | } 77 | 78 | async getActiveAccountId() { 79 | try { 80 | const props = getProps(this) 81 | // account tokens are scoped to one account 82 | if (props.type === 'account_token') { 83 | return props.account.id 84 | } 85 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 86 | // we do this so we can persist activeAccountId across sessions 87 | const userDetails = getUserDetails(env, props.user.id) 88 | return await userDetails.getActiveAccountId() 89 | } catch (e) { 90 | this.server.recordError(e) 91 | return null 92 | } 93 | } 94 | 95 | async setActiveAccountId(accountId: string) { 96 | try { 97 | const props = getProps(this) 98 | // account tokens are scoped to one account 99 | if (props.type === 'account_token') { 100 | return 101 | } 102 | const userDetails = getUserDetails(env, props.user.id) 103 | await userDetails.setActiveAccountId(accountId) 104 | } catch (e) { 105 | this.server.recordError(e) 106 | } 107 | } 108 | } 109 | 110 | const AnalyticsScopes = { 111 | ...RequiredScopes, 112 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 113 | 'zone:read': 'See your zones', 114 | 'dns_settings:read': 'See your DNS settings', 115 | 'dns_analytics:read': 'See your DNS analytics', 116 | } as const 117 | 118 | export default { 119 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 120 | if (await isApiTokenRequest(req, env)) { 121 | return await handleApiTokenMode(DNSAnalyticsMCP, req, env, ctx) 122 | } 123 | 124 | return new OAuthProvider({ 125 | apiHandlers: { 126 | '/mcp': DNSAnalyticsMCP.serve('/mcp'), 127 | '/sse': DNSAnalyticsMCP.serveSSE('/sse'), 128 | }, 129 | // @ts-ignore 130 | defaultHandler: createAuthHandlers({ scopes: AnalyticsScopes, metrics }), 131 | authorizeEndpoint: '/oauth/authorize', 132 | tokenEndpoint: '/token', 133 | tokenExchangeCallback: (options) => 134 | handleTokenExchangeCallback( 135 | options, 136 | env.CLOUDFLARE_CLIENT_ID, 137 | env.CLOUDFLARE_CLIENT_SECRET 138 | ), 139 | // Cloudflare access token TTL 140 | accessTokenTTL: 3600, 141 | clientRegistrationEndpoint: '/register', 142 | }).fetch(req, env, ctx) 143 | }, 144 | } 145 | ``` -------------------------------------------------------------------------------- /apps/sandbox-container/container/sandbox.container.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { exec } from 'node:child_process' 2 | import * as fs from 'node:fs/promises' 3 | import path from 'node:path' 4 | import { serve } from '@hono/node-server' 5 | import { zValidator } from '@hono/zod-validator' 6 | import { Hono } from 'hono' 7 | import { streamText } from 'hono/streaming' 8 | import mime from 'mime' 9 | 10 | import { ExecParams, FileWrite } from '../shared/schema.ts' 11 | import { 12 | DIRECTORY_CONTENT_TYPE, 13 | get_file_name_from_path, 14 | get_mime_type, 15 | list_files_in_directory, 16 | } from './fileUtils.ts' 17 | 18 | import type { FileList } from '../shared/schema.ts' 19 | 20 | process.chdir('workdir') 21 | 22 | const app = new Hono() 23 | 24 | app.get('/ping', (c) => c.text('pong!')) 25 | 26 | /** 27 | * GET /files/ls 28 | * 29 | * Gets all files in a directory 30 | */ 31 | app.get('/files/ls', async (c) => { 32 | const directoriesToRead = ['.'] 33 | const files: FileList = { resources: [] } 34 | 35 | while (directoriesToRead.length > 0) { 36 | const curr = directoriesToRead.pop() 37 | if (!curr) { 38 | throw new Error('Popped empty stack, error while listing directories') 39 | } 40 | const fullPath = path.join(process.cwd(), curr) 41 | const dir = await fs.readdir(fullPath, { withFileTypes: true }) 42 | for (const dirent of dir) { 43 | const relPath = path.relative(process.cwd(), `${fullPath}/${dirent.name}`) 44 | if (dirent.isDirectory()) { 45 | directoriesToRead.push(dirent.name) 46 | files.resources.push({ 47 | uri: `file:///${relPath}`, 48 | name: dirent.name, 49 | mimeType: 'inode/directory', 50 | }) 51 | } else { 52 | const mimeType = mime.getType(dirent.name) 53 | files.resources.push({ 54 | uri: `file:///${relPath}`, 55 | name: dirent.name, 56 | mimeType: mimeType ?? undefined, 57 | }) 58 | } 59 | } 60 | } 61 | 62 | return c.json(files) 63 | }) 64 | 65 | /** 66 | * GET /files/contents/{filepath} 67 | * 68 | * Get the contents of a file or directory 69 | */ 70 | app.get('/files/contents/*', async (c) => { 71 | const reqPath = await get_file_name_from_path(c.req.path) 72 | try { 73 | const mimeType = await get_mime_type(reqPath) 74 | const headers = mimeType ? { 'Content-Type': mimeType } : undefined 75 | const contents = await fs.readFile(path.join(process.cwd(), reqPath)) 76 | return c.newResponse(contents, 200, headers) 77 | } catch (e: any) { 78 | if (e.code) { 79 | if (e.code === 'EISDIR') { 80 | const files = await list_files_in_directory(reqPath) 81 | return c.newResponse(files.join('\n'), 200, { 82 | 'Content-Type': DIRECTORY_CONTENT_TYPE, 83 | }) 84 | } 85 | if (e.code === 'ENOENT') { 86 | return c.notFound() 87 | } 88 | } 89 | 90 | throw e 91 | } 92 | }) 93 | 94 | /** 95 | * POST /files/contents 96 | * 97 | * Create or update file contents 98 | */ 99 | app.post('/files/contents', zValidator('json', FileWrite), async (c) => { 100 | const file = c.req.valid('json') 101 | const reqPath = await get_file_name_from_path(file.path) 102 | 103 | try { 104 | await fs.writeFile(reqPath, file.text) 105 | return c.newResponse(null, 200) 106 | } catch (e) { 107 | return c.newResponse(`Error: ${e}`, 400) 108 | } 109 | }) 110 | 111 | /** 112 | * DELETE /files/contents/{filepath} 113 | * 114 | * Delete a file or directory 115 | */ 116 | app.delete('/files/contents/*', async (c) => { 117 | const reqPath = await get_file_name_from_path(c.req.path) 118 | 119 | try { 120 | await fs.rm(path.join(process.cwd(), reqPath), { recursive: true }) 121 | return c.newResponse('ok', 200) 122 | } catch (e: any) { 123 | if (e.code) { 124 | if (e.code === 'ENOENT') { 125 | return c.notFound() 126 | } 127 | } 128 | 129 | throw e 130 | } 131 | }) 132 | 133 | /** 134 | * POST /exec 135 | * 136 | * Execute a command in a shell 137 | */ 138 | app.post('/exec', zValidator('json', ExecParams), (c) => { 139 | const execParams = c.req.valid('json') 140 | const proc = exec(execParams.args) 141 | return streamText(c, async (stream) => { 142 | return new Promise((resolve, reject) => { 143 | if (proc.stdout) { 144 | // Stream data from stdout 145 | proc.stdout.on('data', async (data) => { 146 | await stream.write(data.toString()) 147 | }) 148 | } else { 149 | void stream.write('WARNING: no stdout stream for process') 150 | } 151 | 152 | if (execParams.streamStderr) { 153 | if (proc.stderr) { 154 | proc.stderr.on('data', async (data) => { 155 | await stream.write(data.toString()) 156 | }) 157 | } else { 158 | void stream.write('WARNING: no stderr stream for process') 159 | } 160 | } 161 | 162 | // Handle process exit 163 | proc.on('exit', async (code) => { 164 | await stream.write(`Process exited with code: ${code}`) 165 | if (code === 0) { 166 | await stream.close() 167 | resolve() 168 | } else { 169 | console.error(`Process exited with code ${code}`) 170 | reject(new Error(`Process failed with code ${code}`)) 171 | } 172 | }) 173 | 174 | proc.on('error', (err) => { 175 | console.error('Error with process: ', err) 176 | reject(err) 177 | }) 178 | }) 179 | }) 180 | }) 181 | 182 | serve({ 183 | fetch: app.fetch, 184 | port: 8080, 185 | }) 186 | ``` -------------------------------------------------------------------------------- /apps/graphql/src/graphql.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { getProps } from '@repo/mcp-common/src/get-props' 12 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 13 | import { initSentryWithUser } from '@repo/mcp-common/src/sentry' 14 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 15 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 16 | import { registerZoneTools } from '@repo/mcp-common/src/tools/zone.tools' 17 | import { MetricsTracker } from '@repo/mcp-observability' 18 | 19 | import { registerGraphQLTools } from './tools/graphql.tools' 20 | 21 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 22 | import type { Env } from './graphql.context' 23 | 24 | export { UserDetails } 25 | 26 | const env = getEnv<Env>() 27 | 28 | const metrics = new MetricsTracker(env.MCP_METRICS, { 29 | name: env.MCP_SERVER_NAME, 30 | version: env.MCP_SERVER_VERSION, 31 | }) 32 | 33 | // Context from the auth process, encrypted & stored in the auth token 34 | // and provided to the DurableMCP as this.props 35 | type Props = AuthProps 36 | type State = { activeAccountId: string | null } 37 | 38 | export class GraphQLMCP extends McpAgent<Env, State, Props> { 39 | _server: CloudflareMCPServer | undefined 40 | set server(server: CloudflareMCPServer) { 41 | this._server = server 42 | } 43 | 44 | get server(): CloudflareMCPServer { 45 | if (!this._server) { 46 | throw new Error('Tried to access server before it was initialized') 47 | } 48 | 49 | return this._server 50 | } 51 | 52 | constructor(ctx: DurableObjectState, env: Env) { 53 | super(ctx, env) 54 | } 55 | 56 | async init() { 57 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 58 | const props = getProps(this) 59 | const userId = props.type === 'user_token' ? props.user.id : undefined 60 | const sentry = 61 | props.type === 'user_token' ? initSentryWithUser(env, this.ctx, props.user.id) : undefined 62 | 63 | this.server = new CloudflareMCPServer({ 64 | userId, 65 | wae: this.env.MCP_METRICS, 66 | serverInfo: { 67 | name: this.env.MCP_SERVER_NAME, 68 | version: this.env.MCP_SERVER_VERSION, 69 | }, 70 | sentry, 71 | }) 72 | 73 | // Register account tools 74 | registerAccountTools(this) 75 | 76 | // Register zone tools 77 | registerZoneTools(this) 78 | 79 | // Register GraphQL tools 80 | registerGraphQLTools(this) 81 | } 82 | 83 | async getActiveAccountId() { 84 | try { 85 | const props = getProps(this) 86 | // account tokens are scoped to one account 87 | if (props.type === 'account_token') { 88 | return props.account.id 89 | } 90 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 91 | // we do this so we can persist activeAccountId across sessions 92 | const userDetails = getUserDetails(env, props.user.id) 93 | return await userDetails.getActiveAccountId() 94 | } catch (e) { 95 | this.server.recordError(e) 96 | return null 97 | } 98 | } 99 | 100 | async setActiveAccountId(accountId: string) { 101 | try { 102 | const props = getProps(this) 103 | // account tokens are scoped to one account 104 | if (props.type === 'account_token') { 105 | return 106 | } 107 | const userDetails = getUserDetails(env, props.user.id) 108 | await userDetails.setActiveAccountId(accountId) 109 | } catch (e) { 110 | this.server.recordError(e) 111 | } 112 | } 113 | } 114 | 115 | const GraphQLScopes = { 116 | ...RequiredScopes, 117 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 118 | 'zone:read': 'See zone data such as settings, analytics, and DNS records.', 119 | } as const 120 | 121 | export default { 122 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 123 | if (await isApiTokenRequest(req, env)) { 124 | return await handleApiTokenMode(GraphQLMCP, req, env, ctx) 125 | } 126 | 127 | return new OAuthProvider({ 128 | apiHandlers: { 129 | '/mcp': GraphQLMCP.serve('/mcp'), 130 | '/sse': GraphQLMCP.serveSSE('/sse'), 131 | }, 132 | // @ts-ignore 133 | defaultHandler: createAuthHandlers({ scopes: GraphQLScopes, metrics }), 134 | authorizeEndpoint: '/oauth/authorize', 135 | tokenEndpoint: '/token', 136 | tokenExchangeCallback: (options) => 137 | handleTokenExchangeCallback( 138 | options, 139 | env.CLOUDFLARE_CLIENT_ID, 140 | env.CLOUDFLARE_CLIENT_SECRET 141 | ), 142 | // Cloudflare access token TTL 143 | accessTokenTTL: 3600, 144 | clientRegistrationEndpoint: '/register', 145 | }).fetch(req, env, ctx) 146 | }, 147 | } 148 | ``` -------------------------------------------------------------------------------- /apps/autorag/src/tools/autorag.tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { V4PagePaginationArray } from 'cloudflare/src/pagination.js' 2 | import { z } from 'zod' 3 | 4 | import { getCloudflareClient } from '@repo/mcp-common/src/cloudflare-api' 5 | import { getProps } from '@repo/mcp-common/src/get-props' 6 | 7 | import { pageParam, perPageParam } from '../types' 8 | 9 | import type { AutoRAGMCP } from '../autorag.app' 10 | 11 | export function registerAutoRAGTools(agent: AutoRAGMCP) { 12 | agent.server.tool( 13 | 'list_rags', 14 | 'List AutoRAGs (vector stores)', 15 | { 16 | page: pageParam, 17 | per_page: perPageParam, 18 | }, 19 | async (params) => { 20 | const accountId = await agent.getActiveAccountId() 21 | if (!accountId) { 22 | return { 23 | content: [ 24 | { 25 | type: 'text', 26 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 27 | }, 28 | ], 29 | } 30 | } 31 | try { 32 | const props = getProps(agent) 33 | const client = getCloudflareClient(props.accessToken) 34 | const r = (await client.getAPIList( 35 | `/accounts/${accountId}/autorag/rags`, 36 | // @ts-ignore 37 | V4PagePaginationArray, 38 | { query: { page: params.page, per_page: params.per_page } } 39 | )) as unknown as { 40 | result: Array<{ id: string; source: string; paused: boolean }> 41 | result_info: { total_count: number } 42 | } 43 | 44 | return { 45 | content: [ 46 | { 47 | type: 'text', 48 | text: JSON.stringify({ 49 | autorags: r.result.map((obj) => { 50 | return { 51 | id: obj.id, 52 | source: obj.source, 53 | paused: obj.paused, 54 | } 55 | }), 56 | total_count: r.result_info.total_count, 57 | }), 58 | }, 59 | ], 60 | } 61 | } catch (error) { 62 | return { 63 | content: [ 64 | { 65 | type: 'text', 66 | text: `Error listing rags: ${error instanceof Error && error.message}`, 67 | }, 68 | ], 69 | } 70 | } 71 | } 72 | ) 73 | 74 | agent.server.tool( 75 | 'search', 76 | 'Search Documents using AutoRAG (vector store)', 77 | { 78 | rag_id: z.string().describe('ID of the AutoRAG to search'), 79 | query: z.string().describe('Query to search for. Can be a URL, a title, or a snippet.'), 80 | }, 81 | async (params) => { 82 | try { 83 | const accountId = await agent.getActiveAccountId() 84 | if (!accountId) { 85 | return { 86 | content: [ 87 | { 88 | type: 'text', 89 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 90 | }, 91 | ], 92 | } 93 | } 94 | 95 | const props = getProps(agent) 96 | const client = getCloudflareClient(props.accessToken) 97 | const r = (await client.post( 98 | `/accounts/${accountId}/autorag/rags/${params.rag_id}/search`, 99 | { 100 | body: { 101 | query: params.query, 102 | max_num_results: 5, 103 | }, 104 | } 105 | )) as { result: { data: Array<{ filename: string; content: Array<{ text: string }> }> } } 106 | 107 | const chunks = r.result.data 108 | .map((item) => { 109 | const data = item.content 110 | .map((content) => { 111 | return content.text 112 | }) 113 | .join('\n\n') 114 | 115 | return `<file name="${item.filename}">${data}</file>` 116 | }) 117 | .join('\n\n') 118 | 119 | return { 120 | content: [ 121 | { 122 | type: 'text', 123 | text: chunks, 124 | }, 125 | ], 126 | } 127 | } catch (error) { 128 | return { 129 | content: [ 130 | { 131 | type: 'text', 132 | text: `Error searching rag: ${error instanceof Error && error.message}`, 133 | }, 134 | ], 135 | } 136 | } 137 | } 138 | ) 139 | 140 | agent.server.tool( 141 | 'ai_search', 142 | 'AI Search Documents using AutoRAG (vector store)', 143 | { 144 | rag_id: z.string().describe('ID of the AutoRAG to search'), 145 | query: z.string().describe('Query to search for. Can be a URL, a title, or a snippet.'), 146 | }, 147 | async (params) => { 148 | try { 149 | const accountId = await agent.getActiveAccountId() 150 | if (!accountId) { 151 | return { 152 | content: [ 153 | { 154 | type: 'text', 155 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 156 | }, 157 | ], 158 | } 159 | } 160 | 161 | const props = getProps(agent) 162 | const client = getCloudflareClient(props.accessToken) 163 | const r = (await client.post( 164 | `/accounts/${accountId}/autorag/rags/${params.rag_id}/ai-search`, 165 | { 166 | body: { 167 | query: params.query, 168 | max_num_results: 10, // Limit can be bigger here, since llm is only getting the end response and not individual chunks 169 | }, 170 | } 171 | )) as { result: { response: string } } 172 | 173 | return { 174 | content: [ 175 | { 176 | type: 'text', 177 | text: r.result.response, 178 | }, 179 | ], 180 | } 181 | } catch (error) { 182 | return { 183 | content: [ 184 | { 185 | type: 'text', 186 | text: `Error searching rag: ${error instanceof Error && error.message}`, 187 | }, 188 | ], 189 | } 190 | } 191 | } 192 | ) 193 | } 194 | ``` -------------------------------------------------------------------------------- /apps/sandbox-container/server/userContainer.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { DurableObject } from 'cloudflare:workers' 2 | 3 | import { OPEN_CONTAINER_PORT } from '../shared/consts' 4 | import { MAX_CONTAINERS, proxyFetch, startAndWaitForPort } from './containerHelpers' 5 | import { getContainerManager } from './containerManager' 6 | import { fileToBase64 } from './utils' 7 | 8 | import type { ExecParams, FileList, FileWrite } from '../shared/schema' 9 | import type { Env } from './sandbox.server.context' 10 | 11 | export class UserContainer extends DurableObject<Env> { 12 | constructor( 13 | public ctx: DurableObjectState, 14 | public env: Env 15 | ) { 16 | console.log('creating user container DO') 17 | super(ctx, env) 18 | } 19 | 20 | async destroyContainer(): Promise<void> { 21 | await this.ctx.container?.destroy() 22 | } 23 | 24 | async killContainer(): Promise<void> { 25 | console.log('Reaping container') 26 | const containerManager = getContainerManager(this.env) 27 | const active = await containerManager.listActive() 28 | if (this.ctx.id.toString() in active) { 29 | console.log('killing container') 30 | await this.destroyContainer() 31 | await containerManager.killContainer(this.ctx.id.toString()) 32 | } 33 | } 34 | 35 | async container_initialize(): Promise<string> { 36 | // kill container 37 | await this.killContainer() 38 | 39 | // try to cleanup cleanup old containers 40 | const containerManager = getContainerManager(this.env) 41 | 42 | // if more than half of our containers are being used, let's try reaping 43 | if ((await containerManager.listActive()).length >= MAX_CONTAINERS / 2) { 44 | await containerManager.tryKillOldContainers() 45 | if ((await containerManager.listActive()).length >= MAX_CONTAINERS) { 46 | throw new Error( 47 | `Unable to reap enough containers. There are ${MAX_CONTAINERS} active container sandboxes, please wait` 48 | ) 49 | } 50 | } 51 | 52 | // start container 53 | let startedContainer = false 54 | await this.ctx.blockConcurrencyWhile(async () => { 55 | startedContainer = await startAndWaitForPort( 56 | this.env.ENVIRONMENT, 57 | this.ctx.container, 58 | OPEN_CONTAINER_PORT 59 | ) 60 | }) 61 | if (!startedContainer) { 62 | throw new Error('Failed to start container') 63 | } 64 | 65 | // track and manage lifecycle 66 | await containerManager.trackContainer(this.ctx.id.toString()) 67 | 68 | return `Created new container` 69 | } 70 | 71 | async container_ping(): Promise<string> { 72 | const res = await proxyFetch( 73 | this.env.ENVIRONMENT, 74 | this.ctx.container, 75 | new Request(`http://host:${OPEN_CONTAINER_PORT}/ping`), 76 | OPEN_CONTAINER_PORT 77 | ) 78 | if (!res || !res.ok) { 79 | throw new Error(`Request to container failed: ${await res.text()}`) 80 | } 81 | return await res.text() 82 | } 83 | 84 | async container_exec(params: ExecParams): Promise<string> { 85 | const res = await proxyFetch( 86 | this.env.ENVIRONMENT, 87 | this.ctx.container, 88 | new Request(`http://host:${OPEN_CONTAINER_PORT}/exec`, { 89 | method: 'POST', 90 | body: JSON.stringify(params), 91 | headers: { 92 | 'content-type': 'application/json', 93 | }, 94 | }), 95 | OPEN_CONTAINER_PORT 96 | ) 97 | if (!res || !res.ok) { 98 | throw new Error(`Request to container failed: ${await res.text()}`) 99 | } 100 | const txt = await res.text() 101 | return txt 102 | } 103 | 104 | async container_ls(): Promise<FileList> { 105 | const res = await proxyFetch( 106 | this.env.ENVIRONMENT, 107 | this.ctx.container, 108 | new Request(`http://host:${OPEN_CONTAINER_PORT}/files/ls`), 109 | OPEN_CONTAINER_PORT 110 | ) 111 | if (!res || !res.ok) { 112 | throw new Error(`Request to container failed: ${await res.text()}`) 113 | } 114 | const json = (await res.json()) as FileList 115 | return json 116 | } 117 | 118 | async container_file_delete(filePath: string): Promise<boolean> { 119 | const res = await proxyFetch( 120 | this.env.ENVIRONMENT, 121 | this.ctx.container, 122 | new Request(`http://host:${OPEN_CONTAINER_PORT}/files/contents/${filePath}`, { 123 | method: 'DELETE', 124 | }), 125 | OPEN_CONTAINER_PORT 126 | ) 127 | return res.ok 128 | } 129 | async container_file_read( 130 | filePath: string 131 | ): Promise< 132 | | { type: 'text'; textOutput: string; mimeType: string | undefined } 133 | | { type: 'base64'; base64Output: string; mimeType: string | undefined } 134 | > { 135 | const res = await proxyFetch( 136 | this.env.ENVIRONMENT, 137 | this.ctx.container, 138 | new Request(`http://host:${OPEN_CONTAINER_PORT}/files/contents/${filePath}`), 139 | OPEN_CONTAINER_PORT 140 | ) 141 | if (!res || !res.ok) { 142 | throw new Error(`Request to container failed: ${await res.text()}`) 143 | } 144 | 145 | const mimeType = res.headers.get('Content-Type') ?? undefined 146 | const blob = await res.blob() 147 | 148 | if (mimeType && mimeType.startsWith('text')) { 149 | return { 150 | type: 'text', 151 | textOutput: await blob.text(), 152 | mimeType, 153 | } 154 | } else { 155 | return { 156 | type: 'base64', 157 | base64Output: await fileToBase64(blob), 158 | mimeType, 159 | } 160 | } 161 | } 162 | 163 | async container_file_write(file: FileWrite): Promise<string> { 164 | const res = await proxyFetch( 165 | this.env.ENVIRONMENT, 166 | this.ctx.container, 167 | new Request(`http://host:${OPEN_CONTAINER_PORT}/files/contents`, { 168 | method: 'POST', 169 | body: JSON.stringify(file), 170 | headers: { 171 | 'content-type': 'application/json', 172 | }, 173 | }), 174 | OPEN_CONTAINER_PORT 175 | ) 176 | if (!res || !res.ok) { 177 | throw new Error(`Request to container failed: ${await res.text()}`) 178 | } 179 | return `Wrote file: ${file.path}` 180 | } 181 | } 182 | ``` -------------------------------------------------------------------------------- /packages/mcp-common/src/cloudflare-auth.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod' 2 | 3 | import { McpError } from './mcp-error' 4 | 5 | import type { AuthRequest } from '@cloudflare/workers-oauth-provider' 6 | 7 | // Constants 8 | const PKCE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' 9 | const RECOMMENDED_CODE_VERIFIER_LENGTH = 96 10 | function base64urlEncode(value: string): string { 11 | let base64 = btoa(value) 12 | base64 = base64.replace(/\+/g, '-') 13 | base64 = base64.replace(/\//g, '_') 14 | base64 = base64.replace(/=/g, '') 15 | return base64 16 | } 17 | 18 | interface PKCECodes { 19 | codeChallenge: string 20 | codeVerifier: string 21 | } 22 | async function generatePKCECodes(): Promise<PKCECodes> { 23 | const output = new Uint32Array(RECOMMENDED_CODE_VERIFIER_LENGTH) 24 | crypto.getRandomValues(output) 25 | const codeVerifier = base64urlEncode( 26 | Array.from(output) 27 | .map((num: number) => PKCE_CHARSET[num % PKCE_CHARSET.length]) 28 | .join('') 29 | ) 30 | const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)) 31 | const hash = new Uint8Array(buffer) 32 | let binary = '' 33 | const hashLength = hash.byteLength 34 | for (let i = 0; i < hashLength; i++) { 35 | binary += String.fromCharCode(hash[i]) 36 | } 37 | const codeChallenge = base64urlEncode(binary) //btoa(binary); 38 | return { codeChallenge, codeVerifier } 39 | } 40 | 41 | function generateAuthUrl({ 42 | client_id, 43 | redirect_uri, 44 | state, 45 | code_challenge, 46 | scopes, 47 | }: { 48 | client_id: string 49 | redirect_uri: string 50 | code_challenge: string 51 | state: string 52 | scopes: Record<string, string> 53 | }) { 54 | const params = new URLSearchParams({ 55 | response_type: 'code', 56 | client_id, 57 | redirect_uri, 58 | state, 59 | code_challenge, 60 | code_challenge_method: 'S256', 61 | scope: Object.keys(scopes).join(' '), 62 | }) 63 | 64 | const upstream = new URL(`https://dash.cloudflare.com/oauth2/auth?${params.toString()}`) 65 | return upstream.href 66 | } 67 | 68 | /** 69 | * Constructs an authorization URL for Cloudflare. 70 | * 71 | * @param {Object} options 72 | * @param {string} options.client_id - The client ID of the application. 73 | * @param {string} options.redirect_uri - The redirect URI of the application. 74 | * @param {string} [options.state] - The state parameter. 75 | * 76 | * @returns {string} The authorization URL. 77 | */ 78 | export async function getAuthorizationURL({ 79 | client_id, 80 | redirect_uri, 81 | state, 82 | scopes, 83 | }: { 84 | client_id: string 85 | redirect_uri: string 86 | state: AuthRequest 87 | scopes: Record<string, string> 88 | }): Promise<{ authUrl: string; codeVerifier: string }> { 89 | const { codeChallenge, codeVerifier } = await generatePKCECodes() 90 | 91 | return { 92 | authUrl: generateAuthUrl({ 93 | client_id, 94 | redirect_uri, 95 | state: btoa(JSON.stringify({ ...state, codeVerifier })), 96 | code_challenge: codeChallenge, 97 | scopes, 98 | }), 99 | codeVerifier: codeVerifier, 100 | } 101 | } 102 | 103 | type AuthorizationToken = z.infer<typeof AuthorizationToken> 104 | const AuthorizationToken = z.object({ 105 | access_token: z.string(), 106 | expires_in: z.number(), 107 | refresh_token: z.string(), 108 | scope: z.string(), 109 | token_type: z.string(), 110 | }) 111 | /** 112 | * Fetches an authorization token from Cloudflare. 113 | * 114 | * @param {Object} options 115 | * @param {string} options.client_id - The client ID of the application. 116 | * @param {string} options.client_secret - The client secret of the application. 117 | * @param {string} options.code - The authorization code. 118 | * @param {string} options.redirect_uri - The redirect URI of the application. 119 | * 120 | * @returns {Promise<[string, null] | [null, Response]>} A promise that resolves to an array containing the access token or an error response. 121 | */ 122 | export async function getAuthToken({ 123 | client_id, 124 | client_secret, 125 | redirect_uri, 126 | code_verifier, 127 | code, 128 | }: { 129 | client_id: string 130 | client_secret: string 131 | redirect_uri: string 132 | code_verifier: string 133 | code: string 134 | }): Promise<AuthorizationToken> { 135 | if (!code) { 136 | throw new McpError('Missing code', 400) 137 | } 138 | 139 | const params = new URLSearchParams({ 140 | grant_type: 'authorization_code', 141 | client_id, 142 | redirect_uri, 143 | code, 144 | code_verifier, 145 | }).toString() 146 | const resp = await fetch('https://dash.cloudflare.com/oauth2/token', { 147 | method: 'POST', 148 | headers: { 149 | Authorization: `Basic ${btoa(`${client_id}:${client_secret}`)}`, 150 | 'Content-Type': 'application/x-www-form-urlencoded', 151 | }, 152 | body: params, 153 | }) 154 | 155 | if (!resp.ok) { 156 | console.log(await resp.text()) 157 | throw new McpError('Failed to get OAuth token', 500, { reportToSentry: true }) 158 | } 159 | 160 | return AuthorizationToken.parse(await resp.json()) 161 | } 162 | 163 | export async function refreshAuthToken({ 164 | client_id, 165 | client_secret, 166 | refresh_token, 167 | }: { 168 | client_id: string 169 | client_secret: string 170 | refresh_token: string 171 | }): Promise<AuthorizationToken> { 172 | const params = new URLSearchParams({ 173 | grant_type: 'refresh_token', 174 | client_id, 175 | refresh_token, 176 | }) 177 | 178 | const resp = await fetch('https://dash.cloudflare.com/oauth2/token', { 179 | method: 'POST', 180 | body: params.toString(), 181 | headers: { 182 | Authorization: `Basic ${btoa(`${client_id}:${client_secret}`)}`, 183 | 'Content-Type': 'application/x-www-form-urlencoded', 184 | }, 185 | }) 186 | if (!resp.ok) { 187 | console.log(await resp.text()) 188 | throw new McpError('Failed to get OAuth token', 500, { reportToSentry: true }) 189 | } 190 | 191 | return AuthorizationToken.parse(await resp.json()) 192 | } 193 | ``` -------------------------------------------------------------------------------- /apps/workers-bindings/src/bindings.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { getProps } from '@repo/mcp-common/src/get-props' 12 | import { registerPrompts } from '@repo/mcp-common/src/prompts/docs-vectorize.prompts' 13 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 14 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 15 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 16 | import { registerD1Tools } from '@repo/mcp-common/src/tools/d1.tools' 17 | import { registerDocsTools } from '@repo/mcp-common/src/tools/docs-vectorize.tools' 18 | import { registerHyperdriveTools } from '@repo/mcp-common/src/tools/hyperdrive.tools' 19 | import { registerKVTools } from '@repo/mcp-common/src/tools/kv_namespace.tools' 20 | import { registerR2BucketTools } from '@repo/mcp-common/src/tools/r2_bucket.tools' 21 | import { registerWorkersTools } from '@repo/mcp-common/src/tools/worker.tools' 22 | import { MetricsTracker } from '@repo/mcp-observability' 23 | 24 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 25 | import type { Env } from './bindings.context' 26 | 27 | export { UserDetails } 28 | 29 | const env = getEnv<Env>() 30 | 31 | const metrics = new MetricsTracker(env.MCP_METRICS, { 32 | name: env.MCP_SERVER_NAME, 33 | version: env.MCP_SERVER_VERSION, 34 | }) 35 | 36 | export type WorkersBindingsMCPState = { activeAccountId: string | null } 37 | 38 | // Context from the auth process, encrypted & stored in the auth token 39 | // and provided to the DurableMCP as this.props 40 | type Props = AuthProps 41 | 42 | export class WorkersBindingsMCP extends McpAgent<Env, WorkersBindingsMCPState, Props> { 43 | _server: CloudflareMCPServer | undefined 44 | set server(server: CloudflareMCPServer) { 45 | this._server = server 46 | } 47 | 48 | get server(): CloudflareMCPServer { 49 | if (!this._server) { 50 | throw new Error('Tried to access server before it was initialized') 51 | } 52 | 53 | return this._server 54 | } 55 | 56 | initialState: WorkersBindingsMCPState = { 57 | activeAccountId: null, 58 | } 59 | 60 | constructor(ctx: DurableObjectState, env: Env) { 61 | super(ctx, env) 62 | } 63 | 64 | async init() { 65 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 66 | const props = getProps(this) 67 | const userId = props.type === 'user_token' ? props.user.id : undefined 68 | 69 | this.server = new CloudflareMCPServer({ 70 | userId, 71 | wae: this.env.MCP_METRICS, 72 | serverInfo: { 73 | name: this.env.MCP_SERVER_NAME, 74 | version: this.env.MCP_SERVER_VERSION, 75 | }, 76 | }) 77 | 78 | registerAccountTools(this) 79 | registerKVTools(this) 80 | registerWorkersTools(this) 81 | registerR2BucketTools(this) 82 | registerD1Tools(this) 83 | registerHyperdriveTools(this) 84 | 85 | // Add docs tools 86 | registerDocsTools(this, this.env) 87 | registerPrompts(this) 88 | } 89 | 90 | async getActiveAccountId() { 91 | try { 92 | const props = getProps(this) 93 | // account tokens are scoped to one account 94 | if (props.type === 'account_token') { 95 | return props.account.id 96 | } 97 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 98 | // we do this so we can persist activeAccountId across sessions 99 | const userDetails = getUserDetails(env, props.user.id) 100 | return await userDetails.getActiveAccountId() 101 | } catch (e) { 102 | this.server.recordError(e) 103 | return null 104 | } 105 | } 106 | 107 | async setActiveAccountId(accountId: string) { 108 | try { 109 | const props = getProps(this) 110 | // account tokens are scoped to one account 111 | if (props.type === 'account_token') { 112 | return 113 | } 114 | const userDetails = getUserDetails(env, props.user.id) 115 | await userDetails.setActiveAccountId(accountId) 116 | } catch (e) { 117 | this.server.recordError(e) 118 | } 119 | } 120 | } 121 | 122 | const BindingsScopes = { 123 | ...RequiredScopes, 124 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 125 | 'workers:write': 126 | 'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.', 127 | 'd1:write': 'Create, read, and write to D1 databases', 128 | } as const 129 | 130 | export default { 131 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 132 | if (await isApiTokenRequest(req, env)) { 133 | console.log('is token mode') 134 | return await handleApiTokenMode(WorkersBindingsMCP, req, env, ctx) 135 | } 136 | 137 | return new OAuthProvider({ 138 | apiHandlers: { 139 | '/mcp': WorkersBindingsMCP.serve('/mcp'), 140 | '/sse': WorkersBindingsMCP.serveSSE('/sse'), 141 | }, 142 | // @ts-ignore 143 | defaultHandler: createAuthHandlers({ scopes: BindingsScopes, metrics }), 144 | authorizeEndpoint: '/oauth/authorize', 145 | tokenEndpoint: '/token', 146 | tokenExchangeCallback: (options) => 147 | handleTokenExchangeCallback( 148 | options, 149 | env.CLOUDFLARE_CLIENT_ID, 150 | env.CLOUDFLARE_CLIENT_SECRET 151 | ), 152 | // Cloudflare access token TTL 153 | accessTokenTTL: 3600, 154 | clientRegistrationEndpoint: '/register', 155 | }).fetch(req, env, ctx) 156 | }, 157 | } 158 | ``` -------------------------------------------------------------------------------- /packages/mcp-common/src/tools/worker.tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod' 2 | 3 | import { 4 | handleGetWorkersService, 5 | handleWorkerScriptDownload, 6 | handleWorkersList, 7 | } from '../api/workers.api' 8 | import { getCloudflareClient } from '../cloudflare-api' 9 | import { fmt } from '../format' 10 | import { getProps } from '../get-props' 11 | 12 | import type { CloudflareMcpAgent } from '../types/cloudflare-mcp-agent.types' 13 | 14 | /** 15 | * Registers the workers tools with the MCP server 16 | * @param server The MCP server instance 17 | * @param accountId Cloudflare account ID 18 | * @param apiToken Cloudflare API token 19 | */ 20 | // Define the scriptName parameter schema 21 | const workerNameParam = z.string().describe('The name of the worker script to retrieve') 22 | 23 | export function registerWorkersTools(agent: CloudflareMcpAgent) { 24 | // Tool to list all workers 25 | agent.server.tool( 26 | 'workers_list', 27 | fmt.trim(` 28 | List all Workers in your Cloudflare account. 29 | 30 | If you only need details of a single Worker, use workers_get_worker. 31 | `), 32 | {}, 33 | { 34 | title: 'List Workers', 35 | annotations: { 36 | readOnlyHint: true, 37 | destructiveHint: false, 38 | }, 39 | }, 40 | async () => { 41 | const accountId = await agent.getActiveAccountId() 42 | if (!accountId) { 43 | return { 44 | content: [ 45 | { 46 | type: 'text', 47 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 48 | }, 49 | ], 50 | } 51 | } 52 | 53 | try { 54 | const props = getProps(agent) 55 | const results = await handleWorkersList({ 56 | client: getCloudflareClient(props.accessToken), 57 | accountId, 58 | }) 59 | // Extract worker details and sort by created_on date (newest first) 60 | const workers = results 61 | .map((worker) => ({ 62 | name: worker.id, 63 | // The API client doesn't know tag exists. The tag is needed in other places such as Workers Builds 64 | id: z.object({ tag: z.string() }).parse(worker), 65 | modified_on: worker.modified_on || null, 66 | created_on: worker.created_on || null, 67 | })) 68 | // order by created_on desc ( newest first ) 69 | .sort((a, b) => { 70 | if (!a.created_on) return 1 71 | if (!b.created_on) return -1 72 | return new Date(b.created_on).getTime() - new Date(a.created_on).getTime() 73 | }) 74 | 75 | return { 76 | content: [ 77 | { 78 | type: 'text', 79 | text: JSON.stringify({ 80 | workers, 81 | count: workers.length, 82 | }), 83 | }, 84 | ], 85 | } 86 | } catch (e) { 87 | agent.server.recordError(e) 88 | return { 89 | content: [ 90 | { 91 | type: 'text', 92 | text: `Error listing workers: ${e instanceof Error && e.message}`, 93 | }, 94 | ], 95 | } 96 | } 97 | } 98 | ) 99 | 100 | // Tool to get a specific worker's script details 101 | agent.server.tool( 102 | 'workers_get_worker', 103 | 'Get the details of the Cloudflare Worker.', 104 | { 105 | scriptName: workerNameParam, 106 | }, 107 | { 108 | title: 'Get Worker details', 109 | annotations: { 110 | readOnlyHint: true, 111 | destructiveHint: false, 112 | }, 113 | }, 114 | async (params) => { 115 | const accountId = await agent.getActiveAccountId() 116 | if (!accountId) { 117 | return { 118 | content: [ 119 | { 120 | type: 'text', 121 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 122 | }, 123 | ], 124 | } 125 | } 126 | 127 | try { 128 | const props = getProps(agent) 129 | const { scriptName } = params 130 | const res = await handleGetWorkersService({ 131 | apiToken: props.accessToken, 132 | scriptName, 133 | accountId, 134 | }) 135 | 136 | if (!res.result) { 137 | return { 138 | content: [ 139 | { 140 | type: 'text', 141 | text: 'Worker not found', 142 | }, 143 | ], 144 | } 145 | } 146 | 147 | return { 148 | content: [ 149 | { 150 | type: 'text', 151 | text: await fmt.asTSV([ 152 | { 153 | name: res.result.id, 154 | id: res.result.default_environment.script_tag, 155 | }, 156 | ]), 157 | }, 158 | ], 159 | } 160 | } catch (e) { 161 | agent.server.recordError(e) 162 | return { 163 | content: [ 164 | { 165 | type: 'text', 166 | text: `Error retrieving worker script: ${e instanceof Error && e.message}`, 167 | }, 168 | ], 169 | } 170 | } 171 | } 172 | ) 173 | 174 | // Tool to get a specific worker's script content 175 | agent.server.tool( 176 | 'workers_get_worker_code', 177 | 'Get the source code of a Cloudflare Worker. Note: This may be a bundled version of the worker.', 178 | { scriptName: workerNameParam }, 179 | { 180 | title: 'Get Worker code', 181 | annotations: { 182 | readOnlyHint: true, 183 | destructiveHint: false, 184 | }, 185 | }, 186 | async (params) => { 187 | const accountId = await agent.getActiveAccountId() 188 | if (!accountId) { 189 | return { 190 | content: [ 191 | { 192 | type: 'text', 193 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 194 | }, 195 | ], 196 | } 197 | } 198 | 199 | try { 200 | const props = getProps(agent) 201 | const { scriptName } = params 202 | const scriptContent = await handleWorkerScriptDownload({ 203 | client: getCloudflareClient(props.accessToken), 204 | scriptName, 205 | accountId, 206 | }) 207 | return { 208 | content: [ 209 | { 210 | type: 'text', 211 | text: scriptContent, 212 | }, 213 | ], 214 | } 215 | } catch (e) { 216 | agent.server.recordError(e) 217 | return { 218 | content: [ 219 | { 220 | type: 'text', 221 | text: `Error retrieving worker script: ${e instanceof Error && e.message}`, 222 | }, 223 | ], 224 | } 225 | } 226 | } 227 | ) 228 | } 229 | ``` -------------------------------------------------------------------------------- /apps/workers-observability/src/workers-observability.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { getProps } from '@repo/mcp-common/src/get-props' 12 | import { registerPrompts } from '@repo/mcp-common/src/prompts/docs-vectorize.prompts' 13 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 14 | import { initSentryWithUser } from '@repo/mcp-common/src/sentry' 15 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 16 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 17 | import { registerDocsTools } from '@repo/mcp-common/src/tools/docs-vectorize.tools' 18 | import { registerWorkersTools } from '@repo/mcp-common/src/tools/worker.tools' 19 | 20 | import { MetricsTracker } from '../../../packages/mcp-observability/src' 21 | import { registerObservabilityTools } from './tools/workers-observability.tools' 22 | 23 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 24 | import type { Env } from './workers-observability.context' 25 | 26 | export { UserDetails } 27 | 28 | const env = getEnv<Env>() 29 | 30 | const metrics = new MetricsTracker(env.MCP_METRICS, { 31 | name: env.MCP_SERVER_NAME, 32 | version: env.MCP_SERVER_VERSION, 33 | }) 34 | 35 | // Context from the auth process, encrypted & stored in the auth token 36 | // and provided to the DurableMCP as this.props 37 | type Props = AuthProps 38 | 39 | type State = { activeAccountId: string | null } 40 | 41 | export class ObservabilityMCP extends McpAgent<Env, State, Props> { 42 | _server: CloudflareMCPServer | undefined 43 | set server(server: CloudflareMCPServer) { 44 | this._server = server 45 | } 46 | get server(): CloudflareMCPServer { 47 | if (!this._server) { 48 | throw new Error('Tried to access server before it was initialized') 49 | } 50 | 51 | return this._server 52 | } 53 | 54 | async init() { 55 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 56 | const props = getProps(this) 57 | const userId = props.type === 'user_token' ? props.user.id : undefined 58 | const sentry = 59 | props.type === 'user_token' ? initSentryWithUser(env, this.ctx, props.user.id) : undefined 60 | 61 | this.server = new CloudflareMCPServer({ 62 | userId, 63 | wae: this.env.MCP_METRICS, 64 | serverInfo: { 65 | name: this.env.MCP_SERVER_NAME, 66 | version: this.env.MCP_SERVER_VERSION, 67 | }, 68 | sentry, 69 | options: { 70 | instructions: `# Cloudflare Workers Observability Tool 71 | * A cloudflare worker is a serverless function 72 | * Workers Observability is the tool to inspect the logs for your cloudflare Worker 73 | * Each log is a structured JSON payload with keys and values 74 | 75 | 76 | This server allows you to analyze your Cloudflare Workers logs and metrics. 77 | `, 78 | }, 79 | }) 80 | 81 | registerAccountTools(this) 82 | 83 | // Register Cloudflare Workers tools 84 | registerWorkersTools(this) 85 | 86 | // Register Cloudflare Workers logs tools 87 | registerObservabilityTools(this) 88 | 89 | // Add docs tools 90 | registerDocsTools(this, this.env) 91 | registerPrompts(this) 92 | } 93 | 94 | async getActiveAccountId() { 95 | try { 96 | const props = getProps(this) 97 | // account tokens are scoped to one account 98 | if (props.type === 'account_token') { 99 | return props.account.id 100 | } 101 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 102 | // we do this so we can persist activeAccountId across sessions 103 | const userDetails = getUserDetails(env, props.user.id) 104 | return await userDetails.getActiveAccountId() 105 | } catch (e) { 106 | this.server.recordError(e) 107 | return null 108 | } 109 | } 110 | 111 | async setActiveAccountId(accountId: string) { 112 | try { 113 | const props = getProps(this) 114 | // account tokens are scoped to one account 115 | if (props.type === 'account_token') { 116 | return 117 | } 118 | const userDetails = getUserDetails(env, props.user.id) 119 | await userDetails.setActiveAccountId(accountId) 120 | } catch (e) { 121 | this.server.recordError(e) 122 | } 123 | } 124 | } 125 | 126 | const ObservabilityScopes = { 127 | ...RequiredScopes, 128 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 129 | 'workers:read': 130 | 'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.', 131 | 'workers_observability:read': 'See observability logs for your account', 132 | } as const 133 | 134 | export default { 135 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 136 | if (await isApiTokenRequest(req, env)) { 137 | return await handleApiTokenMode(ObservabilityMCP, req, env, ctx) 138 | } 139 | 140 | return new OAuthProvider({ 141 | apiHandlers: { 142 | '/mcp': ObservabilityMCP.serve('/mcp'), 143 | '/sse': ObservabilityMCP.serveSSE('/sse'), 144 | }, 145 | // @ts-ignore 146 | defaultHandler: createAuthHandlers({ scopes: ObservabilityScopes, metrics }), 147 | authorizeEndpoint: '/oauth/authorize', 148 | tokenEndpoint: '/token', 149 | tokenExchangeCallback: (options) => 150 | handleTokenExchangeCallback( 151 | options, 152 | env.CLOUDFLARE_CLIENT_ID, 153 | env.CLOUDFLARE_CLIENT_SECRET 154 | ), 155 | // Cloudflare access token TTL 156 | accessTokenTTL: 3600, 157 | clientRegistrationEndpoint: '/register', 158 | }).fetch(req, env, ctx) 159 | }, 160 | } 161 | ``` -------------------------------------------------------------------------------- /apps/sandbox-container/server/containerMcp.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpAgent } from 'agents/mcp' 2 | 3 | import { getProps } from '@repo/mcp-common/src/get-props' 4 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 5 | 6 | import { ExecParams, FilePathParam, FileWrite } from '../shared/schema' 7 | import { BASE_INSTRUCTIONS } from './prompts' 8 | import { stripProtocolFromFilePath } from './utils' 9 | 10 | import type { Props, UserContainer } from './sandbox.server.app' 11 | import type { Env } from './sandbox.server.context' 12 | 13 | export class ContainerMcpAgent extends McpAgent<Env, never, Props> { 14 | _server: CloudflareMCPServer | undefined 15 | set server(server: CloudflareMCPServer) { 16 | this._server = server 17 | } 18 | 19 | get server(): CloudflareMCPServer { 20 | if (!this._server) { 21 | throw new Error('Tried to access server before it was initialized') 22 | } 23 | 24 | return this._server 25 | } 26 | 27 | get userContainer(): DurableObjectStub<UserContainer> { 28 | const props = getProps(this) 29 | // TODO: Support account scoped tokens? 30 | if (props.type === 'account_token') { 31 | throw new Error('Container server does not currently support account scoped tokens') 32 | } 33 | const userContainer = this.env.USER_CONTAINER.idFromName(props.user.id) 34 | return this.env.USER_CONTAINER.get(userContainer) 35 | } 36 | 37 | constructor( 38 | public ctx: DurableObjectState, 39 | public env: Env 40 | ) { 41 | console.log('creating container DO') 42 | super(ctx, env) 43 | } 44 | 45 | async init() { 46 | const props = getProps(this) 47 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 48 | const userId = props.type === 'user_token' ? props.user.id : undefined 49 | 50 | this.server = new CloudflareMCPServer({ 51 | userId, 52 | wae: this.env.MCP_METRICS, 53 | serverInfo: { 54 | name: this.env.MCP_SERVER_NAME, 55 | version: this.env.MCP_SERVER_VERSION, 56 | }, 57 | options: { instructions: BASE_INSTRUCTIONS }, 58 | }) 59 | 60 | this.server.tool( 61 | 'container_initialize', 62 | `Start or restart the container. 63 | Use this tool to initialize a container before running any python or node.js code that the user requests ro run.`, 64 | async () => { 65 | const props = getProps(this) 66 | if (props.type === 'account_token') { 67 | return { 68 | // TODO: Support account scoped tokens? 69 | // we'll need to add support for an account blocklist in that case 70 | content: [ 71 | { 72 | type: 'text', 73 | text: 'Container server does not currently support account scoped tokens.', 74 | }, 75 | ], 76 | } 77 | } 78 | 79 | const userInBlocklist = await this.env.USER_BLOCKLIST.get(props.user.id) 80 | if (userInBlocklist) { 81 | return { 82 | content: [{ type: 'text', text: 'Blocked from intializing container.' }], 83 | } 84 | } 85 | return { 86 | content: [{ type: 'text', text: await this.userContainer.container_initialize() }], 87 | } 88 | } 89 | ) 90 | 91 | this.server.tool( 92 | 'container_ping', 93 | `Ping the container for liveliness. Use this tool to check if the container is running.`, 94 | async () => { 95 | return { 96 | content: [{ type: 'text', text: await this.userContainer.container_ping() }], 97 | } 98 | } 99 | ) 100 | this.server.tool( 101 | 'container_exec', 102 | `Run a command in a container and return the results from stdout. 103 | If necessary, set a timeout. To debug, stream back standard error. 104 | If you're using python, ALWAYS use python3 alongside pip3`, 105 | { args: ExecParams }, 106 | async ({ args }) => { 107 | return { 108 | content: [{ type: 'text', text: await this.userContainer.container_exec(args) }], 109 | } 110 | } 111 | ) 112 | this.server.tool( 113 | 'container_file_delete', 114 | 'Delete file in the working directory', 115 | { args: FilePathParam }, 116 | async ({ args }) => { 117 | const path = await stripProtocolFromFilePath(args.path) 118 | const deleted = await this.userContainer.container_file_delete(path) 119 | return { 120 | content: [{ type: 'text', text: `File deleted: ${deleted}.` }], 121 | } 122 | } 123 | ) 124 | this.server.tool( 125 | 'container_file_write', 126 | 'Create a new file with the provided contents in the working direcotry, overwriting the file if it already exists', 127 | { args: FileWrite }, 128 | async ({ args }) => { 129 | args.path = await stripProtocolFromFilePath(args.path) 130 | return { 131 | content: [{ type: 'text', text: await this.userContainer.container_file_write(args) }], 132 | } 133 | } 134 | ) 135 | this.server.tool( 136 | 'container_files_list', 137 | 'List working directory file tree. This just reads the contents of the current working directory', 138 | async () => { 139 | // Begin workaround using container read rather than ls: 140 | const readFile = await this.userContainer.container_file_read('.') 141 | return { 142 | content: [ 143 | { 144 | type: 'resource', 145 | resource: { 146 | text: readFile.type === 'text' ? readFile.textOutput : readFile.base64Output, 147 | uri: `file://`, 148 | mimeType: readFile.mimeType, 149 | }, 150 | }, 151 | ], 152 | } 153 | } 154 | ) 155 | this.server.tool( 156 | 'container_file_read', 157 | 'Read a specific file or directory. Use this tool if you would like to read files or display them to the user. This allow you to get a displayable image for the user if there is an image file.', 158 | { args: FilePathParam }, 159 | async ({ args }) => { 160 | const path = await stripProtocolFromFilePath(args.path) 161 | const readFile = await this.userContainer.container_file_read(path) 162 | 163 | return { 164 | content: [ 165 | { 166 | type: 'resource', 167 | resource: { 168 | text: readFile.type === 'text' ? readFile.textOutput : readFile.base64Output, 169 | uri: `file://${path}`, 170 | mimeType: readFile.mimeType, 171 | }, 172 | }, 173 | ], 174 | } 175 | } 176 | ) 177 | } 178 | } 179 | ``` -------------------------------------------------------------------------------- /packages/mcp-common/src/api/cf1-integration.api.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { fetchCloudflareApi } from '../cloudflare-api' 2 | import { 3 | AssetCategoriesResponse, 4 | AssetDetail, 5 | AssetsResponse, 6 | IntegrationResponse, 7 | IntegrationsResponse, 8 | } from '../types/cf1-integrations.types' 9 | import { V4Schema } from '../v4-api' 10 | 11 | import type { z } from 'zod' 12 | import type { 13 | zReturnedAssetCategoriesResult, 14 | zReturnedAssetsResult, 15 | zReturnedIntegrationResult, 16 | zReturnedIntegrationsResult, 17 | } from '../types/cf1-integrations.types' 18 | 19 | interface BaseParams { 20 | accountId: string 21 | apiToken: string 22 | } 23 | 24 | interface PaginationParams { 25 | page?: number 26 | pageSize?: number 27 | } 28 | 29 | type IntegrationParams = BaseParams & { integrationIdParam: string } 30 | type AssetCategoryParams = BaseParams & { type?: string; vendor?: string } 31 | type AssetSearchParams = BaseParams & { searchTerm: string } & PaginationParams 32 | type AssetByIdParams = BaseParams & { assetId: string } 33 | type AssetByCategoryParams = BaseParams & { categoryId: string } & PaginationParams 34 | type AssetByIntegrationParams = BaseParams & { integrationId: string } & PaginationParams 35 | 36 | const buildParams = (baseParams: Record<string, string>, pagination?: PaginationParams) => { 37 | const params = new URLSearchParams(baseParams) 38 | if (pagination?.page) params.append('page', String(pagination.page)) 39 | if (pagination?.pageSize) params.append('page_size', String(pagination.pageSize)) 40 | return params 41 | } 42 | 43 | const buildIntegrationEndpoint = (integrationId: string) => `/casb/integrations/${integrationId}` 44 | const buildAssetEndpoint = (assetId?: string) => 45 | assetId ? `/casb/assets/${assetId}` : '/casb/assets' 46 | const buildAssetCategoryEndpoint = () => '/casb/asset_categories' 47 | 48 | const makeApiCall = async <T>({ 49 | endpoint, 50 | accountId, 51 | apiToken, 52 | responseSchema, 53 | params, 54 | }: { 55 | endpoint: string 56 | accountId: string 57 | apiToken: string 58 | responseSchema: z.ZodType<any> 59 | params?: URLSearchParams 60 | }): Promise<T> => { 61 | try { 62 | const fullEndpoint = params ? `${endpoint}?${params.toString()}` : endpoint 63 | const data = await fetchCloudflareApi({ 64 | endpoint: fullEndpoint, 65 | accountId, 66 | apiToken, 67 | responseSchema, 68 | options: { 69 | method: 'GET', 70 | headers: { 'Content-Type': 'application/json' }, 71 | }, 72 | }) 73 | return data.result as T 74 | } catch (error) { 75 | console.error(`API call failed for ${endpoint}:`, error) 76 | throw error 77 | } 78 | } 79 | 80 | // Resource-specific API call handlers 81 | const makeIntegrationCall = <T>(params: IntegrationParams, responseSchema: z.ZodType<any>) => 82 | makeApiCall<T>({ 83 | endpoint: buildIntegrationEndpoint(params.integrationIdParam), 84 | accountId: params.accountId, 85 | apiToken: params.apiToken, 86 | responseSchema, 87 | }) 88 | 89 | const makeAssetCall = <T>( 90 | params: BaseParams & PaginationParams, 91 | responseSchema: z.ZodType<any>, 92 | assetId?: string, 93 | additionalParams?: Record<string, string> 94 | ) => 95 | makeApiCall<T>({ 96 | endpoint: buildAssetEndpoint(assetId), 97 | accountId: params.accountId, 98 | apiToken: params.apiToken, 99 | responseSchema, 100 | params: buildParams(additionalParams || {}, params), 101 | }) 102 | 103 | const makeAssetCategoryCall = <T>(params: AssetCategoryParams, responseSchema: z.ZodType<any>) => 104 | makeApiCall<T>({ 105 | endpoint: buildAssetCategoryEndpoint(), 106 | accountId: params.accountId, 107 | apiToken: params.apiToken, 108 | responseSchema, 109 | params: buildParams({ 110 | ...(params.vendor && { vendor: params.vendor }), 111 | ...(params.type && { type: params.type }), 112 | }), 113 | }) 114 | 115 | // Integration handlers 116 | export async function handleIntegrationById( 117 | params: IntegrationParams 118 | ): Promise<{ integration: zReturnedIntegrationResult | null }> { 119 | const integration = await makeIntegrationCall<zReturnedIntegrationResult>( 120 | params, 121 | V4Schema(IntegrationResponse) 122 | ) 123 | return { integration } 124 | } 125 | 126 | export async function handleIntegrations( 127 | params: BaseParams 128 | ): Promise<{ integrations: zReturnedIntegrationsResult | null }> { 129 | const integrations = await makeApiCall<zReturnedIntegrationsResult>({ 130 | endpoint: '/casb/integrations', 131 | accountId: params.accountId, 132 | apiToken: params.apiToken, 133 | responseSchema: V4Schema(IntegrationsResponse), 134 | }) 135 | return { integrations } 136 | } 137 | 138 | // Asset category handlers 139 | export async function handleAssetCategories( 140 | params: AssetCategoryParams 141 | ): Promise<{ categories: zReturnedAssetCategoriesResult | null }> { 142 | const categories = await makeAssetCategoryCall<zReturnedAssetCategoriesResult>( 143 | params, 144 | V4Schema(AssetCategoriesResponse) 145 | ) 146 | return { categories } 147 | } 148 | 149 | // Asset handlers 150 | export async function handleAssets(params: BaseParams & PaginationParams) { 151 | const assets = await makeAssetCall<zReturnedAssetsResult>(params, V4Schema(AssetsResponse)) 152 | return { assets } 153 | } 154 | 155 | export async function handleAssetsByIntegrationId(params: AssetByIntegrationParams) { 156 | const assets = await makeAssetCall<zReturnedAssetsResult>( 157 | params, 158 | V4Schema(AssetsResponse), 159 | undefined, 160 | { integration_id: params.integrationId } 161 | ) 162 | return { assets } 163 | } 164 | 165 | export async function handleAssetById(params: AssetByIdParams) { 166 | const asset = await makeAssetCall<zReturnedAssetsResult>( 167 | params, 168 | V4Schema(AssetDetail), 169 | params.assetId 170 | ) 171 | return { asset } 172 | } 173 | 174 | export async function handleAssetsByAssetCategoryId(params: AssetByCategoryParams) { 175 | const assets = await makeAssetCall<zReturnedAssetsResult>( 176 | params, 177 | V4Schema(AssetsResponse), 178 | undefined, 179 | { category_id: params.categoryId } 180 | ) 181 | return { assets } 182 | } 183 | 184 | export async function handleAssetsSearch(params: AssetSearchParams) { 185 | const assets = await makeAssetCall<zReturnedAssetsResult>( 186 | params, 187 | V4Schema(AssetsResponse), 188 | undefined, 189 | { search: params.searchTerm } 190 | ) 191 | return { assets } 192 | } 193 | ``` -------------------------------------------------------------------------------- /apps/ai-gateway/src/tools/ai-gateway.tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getCloudflareClient } from '@repo/mcp-common/src/cloudflare-api' 2 | import { getProps } from '@repo/mcp-common/src/get-props' 3 | 4 | import { GatewayIdParam, ListLogsParams, LogIdParam, pageParam, perPageParam } from '../types' 5 | 6 | import type { LogListParams } from 'cloudflare/resources/ai-gateway' 7 | import type { AIGatewayMCP } from '../ai-gateway.app' 8 | 9 | export function registerAIGatewayTools(agent: AIGatewayMCP) { 10 | agent.server.tool( 11 | 'list_gateways', 12 | 'List Gateways', 13 | { 14 | page: pageParam, 15 | per_page: perPageParam, 16 | }, 17 | async (params) => { 18 | const accountId = await agent.getActiveAccountId() 19 | if (!accountId) { 20 | return { 21 | content: [ 22 | { 23 | type: 'text', 24 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 25 | }, 26 | ], 27 | } 28 | } 29 | try { 30 | const props = getProps(agent) 31 | const client = getCloudflareClient(props.accessToken) 32 | const r = await client.aiGateway.list({ 33 | account_id: accountId, 34 | page: params.page, 35 | per_page: params.per_page, 36 | }) 37 | 38 | return { 39 | content: [ 40 | { 41 | type: 'text', 42 | text: JSON.stringify({ 43 | result: r.result, 44 | result_info: r.result_info, 45 | }), 46 | }, 47 | ], 48 | } 49 | } catch (error) { 50 | return { 51 | content: [ 52 | { 53 | type: 'text', 54 | text: `Error listing gateways: ${error instanceof Error && error.message}`, 55 | }, 56 | ], 57 | } 58 | } 59 | } 60 | ) 61 | 62 | agent.server.tool('list_logs', 'List Logs', ListLogsParams, async (params) => { 63 | try { 64 | const accountId = await agent.getActiveAccountId() 65 | if (!accountId) { 66 | return { 67 | content: [ 68 | { 69 | type: 'text', 70 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 71 | }, 72 | ], 73 | } 74 | } 75 | 76 | const { gateway_id, ...filters } = params 77 | 78 | const props = getProps(agent) 79 | const client = getCloudflareClient(props.accessToken) 80 | const r = await client.aiGateway.logs.list(gateway_id, { 81 | ...filters, 82 | account_id: accountId, 83 | } as LogListParams) 84 | 85 | return { 86 | content: [ 87 | { 88 | type: 'text', 89 | text: JSON.stringify({ 90 | result: r.result, 91 | result_info: r.result_info, 92 | }), 93 | }, 94 | ], 95 | } 96 | } catch (error) { 97 | return { 98 | content: [ 99 | { 100 | type: 'text', 101 | text: `Error listing logs: ${error instanceof Error && error.message}`, 102 | }, 103 | ], 104 | } 105 | } 106 | }) 107 | 108 | agent.server.tool( 109 | 'get_log_details', 110 | 'Get a single Log details', 111 | { 112 | gateway_id: GatewayIdParam, 113 | log_id: LogIdParam, 114 | }, 115 | async (params) => { 116 | const accountId = await agent.getActiveAccountId() 117 | if (!accountId) { 118 | return { 119 | content: [ 120 | { 121 | type: 'text', 122 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 123 | }, 124 | ], 125 | } 126 | } 127 | 128 | try { 129 | const props = getProps(agent) 130 | const client = getCloudflareClient(props.accessToken) 131 | const r = await client.aiGateway.logs.get(params.gateway_id, params.log_id, { 132 | account_id: accountId, 133 | }) 134 | 135 | return { 136 | content: [ 137 | { 138 | type: 'text', 139 | text: JSON.stringify({ 140 | result: r, 141 | }), 142 | }, 143 | ], 144 | } 145 | } catch (error) { 146 | return { 147 | content: [ 148 | { 149 | type: 'text', 150 | text: `Error getting log: ${error instanceof Error && error.message}`, 151 | }, 152 | ], 153 | } 154 | } 155 | } 156 | ) 157 | 158 | agent.server.tool( 159 | 'get_log_request_body', 160 | 'Get Log Request Body', 161 | { 162 | gateway_id: GatewayIdParam, 163 | log_id: LogIdParam, 164 | }, 165 | async (params) => { 166 | const accountId = await agent.getActiveAccountId() 167 | if (!accountId) { 168 | return { 169 | content: [ 170 | { 171 | type: 'text', 172 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 173 | }, 174 | ], 175 | } 176 | } 177 | 178 | try { 179 | const props = getProps(agent) 180 | const client = getCloudflareClient(props.accessToken) 181 | const r = await client.aiGateway.logs.request(params.gateway_id, params.log_id, { 182 | account_id: accountId, 183 | }) 184 | 185 | return { 186 | content: [ 187 | { 188 | type: 'text', 189 | text: JSON.stringify({ 190 | result: r, 191 | }), 192 | }, 193 | ], 194 | } 195 | } catch (error) { 196 | return { 197 | content: [ 198 | { 199 | type: 'text', 200 | text: `Error getting log request body: ${error instanceof Error && error.message}`, 201 | }, 202 | ], 203 | } 204 | } 205 | } 206 | ) 207 | 208 | agent.server.tool( 209 | 'get_log_response_body', 210 | 'Get Log Response Body', 211 | { 212 | gateway_id: GatewayIdParam, 213 | log_id: LogIdParam, 214 | }, 215 | async (params) => { 216 | const accountId = await agent.getActiveAccountId() 217 | if (!accountId) { 218 | return { 219 | content: [ 220 | { 221 | type: 'text', 222 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)', 223 | }, 224 | ], 225 | } 226 | } 227 | 228 | try { 229 | const props = getProps(agent) 230 | const client = getCloudflareClient(props.accessToken) 231 | const r = await client.aiGateway.logs.response(params.gateway_id, params.log_id, { 232 | account_id: accountId, 233 | }) 234 | 235 | return { 236 | content: [ 237 | { 238 | type: 'text', 239 | text: JSON.stringify({ 240 | result: r, 241 | }), 242 | }, 243 | ], 244 | } 245 | } catch (error) { 246 | return { 247 | content: [ 248 | { 249 | type: 'text', 250 | text: `Error getting log response body: ${error instanceof Error && error.message}`, 251 | }, 252 | ], 253 | } 254 | } 255 | } 256 | ) 257 | } 258 | ``` -------------------------------------------------------------------------------- /packages/mcp-common/src/tools/d1.tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod' 2 | 3 | import { getCloudflareClient } from '../cloudflare-api' 4 | import { MISSING_ACCOUNT_ID_RESPONSE } from '../constants' 5 | import { getProps } from '../get-props' 6 | import { type CloudflareMcpAgent } from '../types/cloudflare-mcp-agent.types' 7 | import { 8 | D1DatabaseNameParam, 9 | D1DatabasePrimaryLocationHintParam, 10 | D1DatabaseQueryParamsParam, 11 | D1DatabaseQuerySqlParam, 12 | } from '../types/d1.types' 13 | import { PaginationPageParam, PaginationPerPageParam } from '../types/shared.types' 14 | 15 | export function registerD1Tools(agent: CloudflareMcpAgent) { 16 | agent.server.tool( 17 | 'd1_databases_list', 18 | 'List all of the D1 databases in your Cloudflare account', 19 | { 20 | name: D1DatabaseNameParam.nullable().optional(), 21 | page: PaginationPageParam, 22 | per_page: PaginationPerPageParam, 23 | }, 24 | { 25 | title: 'List D1 databases', 26 | annotations: { 27 | readOnlyHint: true, 28 | }, 29 | }, 30 | async ({ name, page, per_page }) => { 31 | const account_id = await agent.getActiveAccountId() 32 | if (!account_id) { 33 | return MISSING_ACCOUNT_ID_RESPONSE 34 | } 35 | try { 36 | const props = getProps(agent) 37 | const client = getCloudflareClient(props.accessToken) 38 | const listResponse = await client.d1.database.list({ 39 | account_id, 40 | name: name ?? undefined, 41 | page: page ?? undefined, 42 | per_page: per_page ?? undefined, 43 | }) 44 | 45 | return { 46 | content: [ 47 | { 48 | type: 'text', 49 | text: JSON.stringify({ 50 | result: listResponse.result, 51 | result_info: listResponse.result_info, 52 | }), 53 | }, 54 | ], 55 | } 56 | } catch (error) { 57 | return { 58 | content: [ 59 | { 60 | type: 'text', 61 | text: `Error listing D1 databases: ${error instanceof Error && error.message}`, 62 | }, 63 | ], 64 | } 65 | } 66 | } 67 | ) 68 | 69 | agent.server.tool( 70 | 'd1_database_create', 71 | 'Create a new D1 database in your Cloudflare account', 72 | { 73 | name: D1DatabaseNameParam, 74 | primary_location_hint: D1DatabasePrimaryLocationHintParam.nullable().optional(), 75 | }, 76 | { 77 | title: 'Create D1 database', 78 | annotations: { 79 | readOnlyHint: false, 80 | destructiveHint: false, 81 | }, 82 | }, 83 | async ({ name, primary_location_hint }) => { 84 | const account_id = await agent.getActiveAccountId() 85 | if (!account_id) { 86 | return MISSING_ACCOUNT_ID_RESPONSE 87 | } 88 | try { 89 | const props = getProps(agent) 90 | const client = getCloudflareClient(props.accessToken) 91 | const d1Database = await client.d1.database.create({ 92 | account_id, 93 | name, 94 | primary_location_hint: primary_location_hint ?? undefined, 95 | }) 96 | 97 | return { 98 | content: [ 99 | { 100 | type: 'text', 101 | text: JSON.stringify(d1Database), 102 | }, 103 | ], 104 | } 105 | } catch (error) { 106 | return { 107 | content: [ 108 | { 109 | type: 'text', 110 | text: `Error creating D1 database: ${error instanceof Error && error.message}`, 111 | }, 112 | ], 113 | } 114 | } 115 | } 116 | ) 117 | 118 | agent.server.tool( 119 | 'd1_database_delete', 120 | 'Delete a d1 database in your Cloudflare account', 121 | { database_id: z.string() }, 122 | { 123 | title: 'Delete D1 database', 124 | annotations: { 125 | readOnlyHint: false, 126 | destructiveHint: true, 127 | }, 128 | }, 129 | async ({ database_id }) => { 130 | const account_id = await agent.getActiveAccountId() 131 | if (!account_id) { 132 | return MISSING_ACCOUNT_ID_RESPONSE 133 | } 134 | try { 135 | const props = getProps(agent) 136 | const client = getCloudflareClient(props.accessToken) 137 | const deleteResponse = await client.d1.database.delete(database_id, { 138 | account_id, 139 | }) 140 | return { 141 | content: [ 142 | { 143 | type: 'text', 144 | text: JSON.stringify(deleteResponse), 145 | }, 146 | ], 147 | } 148 | } catch (error) { 149 | return { 150 | content: [ 151 | { 152 | type: 'text', 153 | text: `Error deleting D1 database: ${error instanceof Error && error.message}`, 154 | }, 155 | ], 156 | } 157 | } 158 | } 159 | ) 160 | 161 | agent.server.tool( 162 | 'd1_database_get', 163 | 'Get a D1 database in your Cloudflare account', 164 | { database_id: z.string() }, 165 | { 166 | title: 'Get D1 database', 167 | annotations: { 168 | readOnlyHint: true, 169 | }, 170 | }, 171 | async ({ database_id }) => { 172 | const account_id = await agent.getActiveAccountId() 173 | if (!account_id) { 174 | return MISSING_ACCOUNT_ID_RESPONSE 175 | } 176 | try { 177 | const props = getProps(agent) 178 | const client = getCloudflareClient(props.accessToken) 179 | const d1Database = await client.d1.database.get(database_id, { 180 | account_id, 181 | }) 182 | 183 | return { 184 | content: [ 185 | { 186 | type: 'text', 187 | text: JSON.stringify(d1Database), 188 | }, 189 | ], 190 | } 191 | } catch (error) { 192 | return { 193 | content: [ 194 | { 195 | type: 'text', 196 | text: `Error getting D1 database: ${error instanceof Error && error.message}`, 197 | }, 198 | ], 199 | } 200 | } 201 | } 202 | ) 203 | 204 | agent.server.tool( 205 | 'd1_database_query', 206 | 'Query a D1 database in your Cloudflare account', 207 | { 208 | database_id: z.string(), 209 | sql: D1DatabaseQuerySqlParam, 210 | params: D1DatabaseQueryParamsParam.nullable(), 211 | }, 212 | { 213 | title: 'Query D1 database', 214 | annotations: { 215 | readOnlyHint: false, 216 | destructiveHint: false, 217 | }, 218 | }, 219 | async ({ database_id, sql, params }) => { 220 | const account_id = await agent.getActiveAccountId() 221 | if (!account_id) { 222 | return MISSING_ACCOUNT_ID_RESPONSE 223 | } 224 | try { 225 | const props = getProps(agent) 226 | const client = getCloudflareClient(props.accessToken) 227 | const queryResult = await client.d1.database.query(database_id, { 228 | account_id, 229 | sql, 230 | params: params ?? undefined, 231 | }) 232 | return { 233 | content: [ 234 | { 235 | type: 'text', 236 | text: JSON.stringify(queryResult.result), 237 | }, 238 | ], 239 | } 240 | } catch (error) { 241 | return { 242 | content: [ 243 | { 244 | type: 'text', 245 | text: `Error querying D1 database: ${error instanceof Error && error.message}`, 246 | }, 247 | ], 248 | } 249 | } 250 | } 251 | ) 252 | } 253 | ``` -------------------------------------------------------------------------------- /apps/cloudflare-one-casb/src/tools/integrations.tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod' 2 | 3 | import { withAccountCheck } from '@repo/mcp-common/src/api/account.api' 4 | import { 5 | handleAssetById, 6 | handleAssetCategories, 7 | handleAssets, 8 | handleAssetsByAssetCategoryId, 9 | handleAssetsByIntegrationId, 10 | handleAssetsSearch, 11 | handleIntegrationById, 12 | handleIntegrations, 13 | } from '@repo/mcp-common/src/api/cf1-integration.api' 14 | import { 15 | assetCategoryTypeParam, 16 | assetCategoryVendorParam, 17 | } from '@repo/mcp-common/src/types/cf1-integrations.types' 18 | 19 | import type { ToolDefinition } from '@repo/mcp-common/src/types/tools.types' 20 | import type { CASBMCP } from '../cf1-casb.app' 21 | 22 | const PAGE_SIZE = 3 23 | 24 | const integrationIdParam = z.string().describe('The UUID of the integration to analyze') 25 | const assetSearchTerm = z.string().describe('The search keyword for assets') 26 | const assetIdParam = z.string().describe('The UUID of the asset to analyze') 27 | const assetCategoryIdParam = z.string().describe('The UUID of the asset category to analyze') 28 | 29 | const toolDefinitions: Array<ToolDefinition<any>> = [ 30 | { 31 | name: 'integration_by_id', 32 | description: 'Analyze Cloudflare One Integration by ID', 33 | params: { integrationIdParam }, 34 | handler: async ({ 35 | integrationIdParam, 36 | accountId, 37 | apiToken, 38 | }: { 39 | integrationIdParam: string 40 | accountId: string 41 | apiToken: string 42 | }) => { 43 | const { integration } = await handleIntegrationById({ 44 | integrationIdParam, 45 | accountId, 46 | apiToken, 47 | }) 48 | return { integration } 49 | }, 50 | }, 51 | { 52 | name: 'integrations_list', 53 | description: 'List all Cloudflare One Integrations in a given account', 54 | params: {}, 55 | handler: async ({ accountId, apiToken }: { accountId: string; apiToken: string }) => { 56 | const { integrations } = await handleIntegrations({ accountId, apiToken }) 57 | return { integrations } 58 | }, 59 | }, 60 | { 61 | name: 'assets_search', 62 | description: 'Search Assets by keyword', 63 | params: { assetSearchTerm }, 64 | handler: async ({ 65 | assetSearchTerm, 66 | accountId, 67 | apiToken, 68 | }: { 69 | assetSearchTerm: string 70 | accountId: string 71 | apiToken: string 72 | }) => { 73 | const { assets } = await handleAssetsSearch({ 74 | accountId, 75 | apiToken, 76 | searchTerm: assetSearchTerm, 77 | pageSize: PAGE_SIZE, 78 | }) 79 | return { assets } 80 | }, 81 | }, 82 | { 83 | name: 'asset_by_id', 84 | description: 'Search Assets by ID', 85 | params: { assetIdParam }, 86 | handler: async ({ 87 | assetIdParam, 88 | accountId, 89 | apiToken, 90 | }: { 91 | assetIdParam: string 92 | accountId: string 93 | apiToken: string 94 | }) => { 95 | const { asset } = await handleAssetById({ 96 | accountId, 97 | apiToken, 98 | assetId: assetIdParam, 99 | }) 100 | return { asset } 101 | }, 102 | }, 103 | { 104 | name: 'assets_by_integration_id', 105 | description: 'Search Assets by Integration ID', 106 | params: { integrationIdParam }, 107 | handler: async ({ 108 | integrationIdParam, 109 | accountId, 110 | apiToken, 111 | }: { 112 | integrationIdParam: string 113 | accountId: string 114 | apiToken: string 115 | }) => { 116 | const { assets } = await handleAssetsByIntegrationId({ 117 | accountId, 118 | apiToken, 119 | integrationId: integrationIdParam, 120 | pageSize: PAGE_SIZE, 121 | }) 122 | return { assets } 123 | }, 124 | }, 125 | { 126 | name: 'assets_by_category_id', 127 | description: 'Search Assets by Asset Category ID', 128 | params: { assetCategoryIdParam }, 129 | handler: async ({ 130 | assetCategoryIdParam, 131 | accountId, 132 | apiToken, 133 | }: { 134 | assetCategoryIdParam: string 135 | accountId: string 136 | apiToken: string 137 | }) => { 138 | const { assets } = await handleAssetsByAssetCategoryId({ 139 | accountId, 140 | apiToken, 141 | categoryId: assetCategoryIdParam, 142 | pageSize: PAGE_SIZE, 143 | }) 144 | return { assets } 145 | }, 146 | }, 147 | { 148 | name: 'assets_list', 149 | description: 'Paginated list of Assets', 150 | params: {}, 151 | handler: async ({ accountId, apiToken }: { accountId: string; apiToken: string }) => { 152 | const { assets } = await handleAssets({ 153 | accountId, 154 | apiToken, 155 | pageSize: PAGE_SIZE, 156 | }) 157 | return { assets } 158 | }, 159 | }, 160 | { 161 | name: 'asset_categories_list', 162 | description: 'List Asset Categories', 163 | params: {}, 164 | handler: async ({ accountId, apiToken }: { accountId: string; apiToken: string }) => { 165 | const { categories } = await handleAssetCategories({ 166 | accountId, 167 | apiToken, 168 | }) 169 | return { categories } 170 | }, 171 | }, 172 | { 173 | name: 'asset_categories_by_vendor', 174 | description: 'List asset categories by vendor', 175 | params: { assetCategoryVendorParam }, 176 | handler: async ({ 177 | assetCategoryVendorParam, 178 | accountId, 179 | apiToken, 180 | }: { 181 | assetCategoryVendorParam: string 182 | accountId: string 183 | apiToken: string 184 | }) => { 185 | const { categories } = await handleAssetCategories({ 186 | accountId, 187 | apiToken, 188 | vendor: assetCategoryVendorParam, 189 | }) 190 | return { categories } 191 | }, 192 | }, 193 | { 194 | name: 'asset_categories_by_type', 195 | description: 'Search Asset Categories by type', 196 | params: { assetCategoryTypeParam }, 197 | handler: async ({ 198 | assetCategoryTypeParam, 199 | accountId, 200 | apiToken, 201 | }: { 202 | assetCategoryTypeParam?: string 203 | accountId: string 204 | apiToken: string 205 | }) => { 206 | const { categories } = await handleAssetCategories({ 207 | accountId, 208 | apiToken, 209 | type: assetCategoryTypeParam, 210 | }) 211 | return { categories } 212 | }, 213 | }, 214 | { 215 | name: 'asset_categories_by_vendor_and_type', 216 | description: 'Search Asset Categories by vendor and type', 217 | params: { assetCategoryTypeParam, assetCategoryVendorParam }, 218 | handler: async ({ 219 | assetCategoryTypeParam, 220 | assetCategoryVendorParam, 221 | accountId, 222 | apiToken, 223 | }: { 224 | assetCategoryTypeParam?: string 225 | assetCategoryVendorParam: string 226 | accountId: string 227 | apiToken: string 228 | }) => { 229 | const { categories } = await handleAssetCategories({ 230 | accountId, 231 | apiToken, 232 | type: assetCategoryTypeParam, 233 | vendor: assetCategoryVendorParam, 234 | }) 235 | return { categories } 236 | }, 237 | }, 238 | ] 239 | 240 | /** 241 | * Registers the logs analysis tool with the MCP server 242 | * @param agent The MCP server instance 243 | */ 244 | export function registerIntegrationsTools(agent: CASBMCP) { 245 | toolDefinitions.forEach(({ name, description, params, handler }) => { 246 | agent.server.tool(name, description, params, withAccountCheck(agent, handler)) 247 | }) 248 | } 249 | ``` -------------------------------------------------------------------------------- /implementation-guides/type-validators.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Tool Type Validator Implementation Guide 2 | 3 | This guide outlines the best practices for creating Zod validators for the parameters of MCP (Model Context Protocol) tools, particularly those interacting with Cloudflare resources via the Cloudflare Typescript SDK. 4 | 5 | ## Purpose 6 | 7 | Zod validators serve several critical functions: 8 | 9 | 1. **Runtime Validation:** They ensure that the arguments provided to tools (often by an LLM) match the expected format, constraints, and types before the tool logic executes or interacts with external APIs (like the Cloudflare SDK). 10 | 2. **Type Safety:** They provide strong typing for tool parameters within the TypeScript codebase. 11 | 3. **Schema Definition:** They act as a clear, machine-readable definition of a tool's expected inputs, aiding both developers and LLMs in understanding how to use the tool correctly. 12 | 4. **SDK Alignment:** They help maintain alignment with underlying SDKs, catching potential breaking changes. 13 | 14 | ## Core Principles 15 | 16 | ### 1. Link to SDK Types with `z.ZodType` 17 | 18 | When a tool parameter corresponds directly to a parameter in the Cloudflare Node SDK (`cloudflare/resources/...`), **always** link your Zod schema to the SDK type using `z.ZodType<SDKType>`. This creates a compile-time dependency. 19 | 20 | **Why?** 21 | 22 | - **Detect SDK Changes:** If the underlying SDK type changes (e.g., type alias renamed, property added/removed/renamed, type changed from `string` to `string | null`), your Zod schema definition will likely cause a TypeScript error during compilation. This immediately flags the need to update the validator and potentially the tool logic, preventing runtime errors caused by SDK misalignment. 23 | - **Accuracy:** Ensures your validator accurately reflects the type expected by the SDK function you intend to call. 24 | 25 | **Example (`hyperdrive.types.ts`):** 26 | 27 | ```typescript 28 | import { z } from 'zod' 29 | 30 | import type { ConfigCreateParams } from 'cloudflare/resources/hyperdrive/configs.mjs' 31 | 32 | /** Zod schema for a Hyperdrive config name. */ 33 | export const HyperdriveConfigNameSchema: z.ZodType<ConfigCreateParams['name']> = z 34 | .string() 35 | .min(1) 36 | .max(64) 37 | .regex(/^[a-zA-Z0-9_-]+$/) 38 | .describe('The name of the Hyperdrive configuration (alphanumeric, underscore, hyphen)') 39 | 40 | /** Zod schema for the origin database host (IPv4). */ 41 | export const HyperdriveOriginHostSchema: z.ZodType<ConfigCreateParams.PublicDatabase['host']> = z 42 | .string() 43 | .ip({ version: 'v4' }) 44 | .describe('The database host IPv4 address') 45 | ``` 46 | 47 | ### 2. Define Individual Validators Per Field (Avoid Object Schemas for Tool Parameters) 48 | 49 | Define a separate, named Zod schema for **each individual field** that a tool might accept as a parameter. Do **not** group multiple parameters into a single Zod object schema (`z.object({...})`) that the tool accepts as a single `params` argument. 50 | 51 | **Why?** 52 | 53 | - **LLM Clarity:** LLMs generally understand and handle distinct, named parameters better than complex nested objects. Providing individual schemas makes the tool's signature clearer for the LLM to interpret and use correctly. 54 | - **Reusability:** Individual field schemas (like `HyperdriveConfigIdSchema`, `HyperdriveConfigNameSchema`) can be reused across different tools (e.g., `hyperdrive_create`, `hyperdrive_update`, `hyperdrive_get`). 55 | - **Modularity:** Easier to manage, update, and test individual validation rules. 56 | 57 | **Example (`hyperdrive.types.ts` Structure):** 58 | 59 | ```typescript 60 | // --- Base Field Schemas --- 61 | export const HyperdriveConfigIdSchema = z.string().describe(...); 62 | export const HyperdriveConfigNameSchema: z.ZodType<...> = z.string()...describe(...); 63 | export const HyperdriveOriginHostSchema: z.ZodType<...> = z.string()...describe(...); 64 | export const HyperdriveOriginPortSchema: z.ZodType<...> = z.number()...describe(...); 65 | // ... other individual fields 66 | ``` 67 | 68 | **Conceptual Tool Definition (Illustrative):** 69 | 70 | Instead of: 71 | 72 | ```typescript 73 | // DON'T DO THIS for tool params 74 | const CreateParamsSchema = z.object({ 75 | name: HyperdriveConfigNameSchema, 76 | host: HyperdriveOriginHostSchema, 77 | port: HyperdriveOriginPortSchema, 78 | // ... other fields 79 | }) 80 | 81 | // Tool definition would accept one arg: { params: CreateParamsSchema } 82 | ``` 83 | 84 | Do: 85 | 86 | ```typescript 87 | // DO THIS: Tool definition accepts multiple named args 88 | // tool('hyperdrive_create', { 89 | // name: HyperdriveConfigNameSchema, 90 | // host: HyperdriveOriginHostSchema, 91 | // port: HyperdriveOriginPortSchema, 92 | // // ... other named parameters with their individual schemas 93 | // }, ...) 94 | ``` 95 | 96 | ### 3. Use `.describe()` Extensively 97 | 98 | Add a clear, concise `.describe('...')` call to **every** Zod schema you define. 99 | 100 | **Why?** 101 | 102 | - **LLM Context:** The description is often extracted and provided to the LLM as part of the tool's definition, helping it understand the purpose and constraints of each parameter. 103 | - **Developer Documentation:** Serves as inline documentation for developers working with the code. 104 | 105 | **Example (`hyperdrive.types.ts`):** 106 | 107 | ```typescript 108 | /** Zod schema for the list page number. */ 109 | export const HyperdriveListParamPageSchema = z 110 | .number() 111 | .int() 112 | .positive() 113 | .optional() 114 | .describe('Page number of results') // <-- Good description! 115 | ``` 116 | 117 | ## Naming Conventions 118 | 119 | Use a consistent naming convention for your validator schemas. A recommended pattern is: 120 | 121 | `ServiceNameFieldNameSchema` 122 | 123 | - `ServiceName`: The Cloudflare service (e.g., `Hyperdrive`, `KV`, `D1`, `R2`). 124 | - `FieldName`: The specific field being validated (e.g., `ConfigId`, `ConfigName`, `OriginHost`, `ListParamPage`). 125 | - `Schema`: Suffix indicating it's a Zod schema. 126 | 127 | **Examples:** 128 | 129 | - `HyperdriveConfigIdSchema` 130 | - `KVKeySchema` 131 | - `D1DatabaseIdSchema` 132 | - `R2BucketNameSchema` 133 | 134 | ## Location 135 | 136 | Place validators related to a specific service or concept in dedicated files within the `packages/mcp-common/src/types/` directory (e.g., `hyperdrive.types.ts`, `kv.ts`). 137 | 138 | ## Summary 139 | 140 | By following these principles – linking to SDK types, using granular named validators, providing clear descriptions, and maintaining consistent naming – you create robust, maintainable, and LLM-friendly type validation for MCP tools. 141 | ``` -------------------------------------------------------------------------------- /apps/workers-builds/src/workers-builds.app.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OAuthProvider from '@cloudflare/workers-oauth-provider' 2 | import { McpAgent } from 'agents/mcp' 3 | 4 | import { handleApiTokenMode, isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' 5 | import { 6 | createAuthHandlers, 7 | handleTokenExchangeCallback, 8 | } from '@repo/mcp-common/src/cloudflare-oauth-handler' 9 | import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do' 10 | import { getEnv } from '@repo/mcp-common/src/env' 11 | import { fmt } from '@repo/mcp-common/src/format' 12 | import { getProps } from '@repo/mcp-common/src/get-props' 13 | import { RequiredScopes } from '@repo/mcp-common/src/scopes' 14 | import { initSentryWithUser } from '@repo/mcp-common/src/sentry' 15 | import { CloudflareMCPServer } from '@repo/mcp-common/src/server' 16 | import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools' 17 | import { registerWorkersTools } from '@repo/mcp-common/src/tools/worker.tools' 18 | 19 | import { MetricsTracker } from '../../../packages/mcp-observability/src' 20 | import { registerBuildsTools } from './tools/workers-builds.tools' 21 | 22 | import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' 23 | import type { Env } from './workers-builds.context' 24 | 25 | export { UserDetails } 26 | 27 | const env = getEnv<Env>() 28 | 29 | const metrics = new MetricsTracker(env.MCP_METRICS, { 30 | name: env.MCP_SERVER_NAME, 31 | version: env.MCP_SERVER_VERSION, 32 | }) 33 | 34 | // Context from the auth process, encrypted & stored in the auth token 35 | // and provided to the DurableMCP as this.props 36 | type Props = AuthProps 37 | 38 | type State = { 39 | activeAccountId: string | null 40 | activeBuildUUID: string | null 41 | activeWorkerId: string | null 42 | } 43 | 44 | export class BuildsMCP extends McpAgent<Env, State, Props> { 45 | _server: CloudflareMCPServer | undefined 46 | set server(server: CloudflareMCPServer) { 47 | this._server = server 48 | } 49 | get server(): CloudflareMCPServer { 50 | if (!this._server) { 51 | throw new Error('Tried to access server before it was initialized') 52 | } 53 | return this._server 54 | } 55 | 56 | async init() { 57 | // TODO: Probably we'll want to track account tokens usage through an account identifier at some point 58 | const props = getProps(this) 59 | const userId = props.type === 'user_token' ? props.user.id : undefined 60 | const sentry = 61 | props.type === 'user_token' ? initSentryWithUser(env, this.ctx, props.user.id) : undefined 62 | 63 | this.server = new CloudflareMCPServer({ 64 | userId, 65 | wae: this.env.MCP_METRICS, 66 | serverInfo: { 67 | name: this.env.MCP_SERVER_NAME, 68 | version: this.env.MCP_SERVER_VERSION, 69 | }, 70 | sentry, 71 | options: { 72 | instructions: fmt.trim(` 73 | # Cloudflare Workers Builds Tool 74 | * A Cloudflare Worker is a serverless function 75 | * Workers Builds is a CI/CD system for building and deploying your Worker whenever you push code to GitHub/GitLab. 76 | 77 | This server allows you to view and debug Cloudflare Workers Builds for your Workers (NOT Cloudflare Pages). 78 | 79 | To get started, you can list your accounts (accounts_list) and then set an active account (set_active_account). 80 | Once you have an active account, you can list your Workers (workers_list) and set an active Worker (workers_builds_set_active_worker). 81 | You can then list the builds for your Worker (workers_builds_list_builds) and set an active build (workers_builds_set_active_build). 82 | Once you have an active build, you can view the logs (workers_builds_get_build_logs). 83 | `), 84 | }, 85 | }) 86 | 87 | registerAccountTools(this) 88 | 89 | // Register Cloudflare Workers tools 90 | registerWorkersTools(this) 91 | 92 | // Register Cloudflare Workers logs tools 93 | registerBuildsTools(this) 94 | } 95 | 96 | async getActiveAccountId() { 97 | try { 98 | const props = getProps(this) 99 | // account tokens are scoped to one account 100 | if (props.type === 'account_token') { 101 | return props.account.id 102 | } 103 | // Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it 104 | // we do this so we can persist activeAccountId across sessions 105 | const userDetails = getUserDetails(env, props.user.id) 106 | return await userDetails.getActiveAccountId() 107 | } catch (e) { 108 | this.server.recordError(e) 109 | return null 110 | } 111 | } 112 | 113 | async setActiveAccountId(accountId: string) { 114 | try { 115 | const props = getProps(this) 116 | // account tokens are scoped to one account 117 | if (props.type === 'account_token') { 118 | return 119 | } 120 | const userDetails = getUserDetails(env, props.user.id) 121 | await userDetails.setActiveAccountId(accountId) 122 | } catch (e) { 123 | this.server.recordError(e) 124 | } 125 | } 126 | 127 | async getActiveBuildUUID(): Promise<string | null> { 128 | try { 129 | return this.state.activeBuildUUID 130 | } catch (e) { 131 | this.server.recordError(e) 132 | return null 133 | } 134 | } 135 | 136 | async setActiveBuildUUID(buildUUID: string | null): Promise<void> { 137 | try { 138 | this.setState({ 139 | ...this.state, 140 | activeBuildUUID: buildUUID, 141 | }) 142 | } catch (e) { 143 | this.server.recordError(e) 144 | } 145 | } 146 | 147 | async getActiveWorkerId(): Promise<string | null> { 148 | try { 149 | return this.state.activeWorkerId 150 | } catch (e) { 151 | this.server.recordError(e) 152 | return null 153 | } 154 | } 155 | 156 | async setActiveWorkerId(workerId: string | null): Promise<void> { 157 | try { 158 | this.setState({ 159 | ...this.state, 160 | activeWorkerId: workerId, 161 | }) 162 | } catch (e) { 163 | this.server.recordError(e) 164 | } 165 | } 166 | } 167 | 168 | const BuildsScopes = { 169 | ...RequiredScopes, 170 | 'account:read': 'See your account info such as account details, analytics, and memberships.', 171 | 'workers:read': 172 | 'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.', 173 | 'workers_builds:read': 174 | 'See and change Cloudflare Workers Builds data such as builds, build configuration, and logs.', 175 | } as const 176 | 177 | export default { 178 | fetch: async (req: Request, env: Env, ctx: ExecutionContext) => { 179 | if (await isApiTokenRequest(req, env)) { 180 | return await handleApiTokenMode(BuildsMCP, req, env, ctx) 181 | } 182 | 183 | return new OAuthProvider({ 184 | apiHandlers: { 185 | '/mcp': BuildsMCP.serve('/mcp'), 186 | '/sse': BuildsMCP.serveSSE('/sse'), 187 | }, 188 | // @ts-expect-error 189 | defaultHandler: createAuthHandlers({ scopes: BuildsScopes, metrics }), 190 | authorizeEndpoint: '/oauth/authorize', 191 | tokenEndpoint: '/token', 192 | tokenExchangeCallback: (options) => 193 | handleTokenExchangeCallback( 194 | options, 195 | env.CLOUDFLARE_CLIENT_ID, 196 | env.CLOUDFLARE_CLIENT_SECRET 197 | ), 198 | // Cloudflare access token TTL 199 | accessTokenTTL: 3600, 200 | clientRegistrationEndpoint: '/register', 201 | }).fetch(req, env, ctx) 202 | }, 203 | } 204 | ```