This is page 8 of 29. 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-ai-search
│ │ ├── .eslintrc.cjs
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── docs-ai-search.app.ts
│ │ │ └── docs-ai-search.context.ts
│ │ ├── tsconfig.json
│ │ ├── 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-ai-search.prompts.ts
│ │ │ │ └── docs-vectorize.prompts.ts
│ │ │ ├── scopes.ts
│ │ │ ├── sentry.ts
│ │ │ ├── server.ts
│ │ │ ├── tools
│ │ │ │ ├── account.tools.ts
│ │ │ │ ├── d1.tools.ts
│ │ │ │ ├── docs-ai-search.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
│ │ │ └── workers-oauth-utils.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
--------------------------------------------------------------------------------
/packages/mcp-common/src/workers-oauth-utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod'
2 |
3 | import type { AuthRequest, ClientInfo } from '@cloudflare/workers-oauth-provider'
4 |
5 | const COOKIE_NAME = '__Host-MCP_APPROVED_CLIENTS'
6 | const ONE_YEAR_IN_SECONDS = 31536000
7 |
8 | /**
9 | * OAuth error class for handling OAuth-specific errors
10 | */
11 | export class OAuthError extends Error {
12 | constructor(
13 | public code: string,
14 | public description: string,
15 | public statusCode = 400
16 | ) {
17 | super(description)
18 | this.name = 'OAuthError'
19 | }
20 |
21 | toResponse(): Response {
22 | return new Response(
23 | JSON.stringify({
24 | error: this.code,
25 | error_description: this.description,
26 | }),
27 | {
28 | status: this.statusCode,
29 | headers: { 'Content-Type': 'application/json' },
30 | }
31 | )
32 | }
33 | }
34 |
35 | /**
36 | * Imports a secret key string for HMAC-SHA256 signing.
37 | * @param secret - The raw secret key string.
38 | * @returns A promise resolving to the CryptoKey object.
39 | */
40 | async function importKey(secret: string): Promise<CryptoKey> {
41 | if (!secret) {
42 | throw new Error('COOKIE_SECRET is not defined. A secret key is required for signing cookies.')
43 | }
44 | const enc = new TextEncoder()
45 | return crypto.subtle.importKey(
46 | 'raw',
47 | enc.encode(secret),
48 | { hash: 'SHA-256', name: 'HMAC' },
49 | false, // not extractable
50 | ['sign', 'verify'] // key usages
51 | )
52 | }
53 |
54 | /**
55 | * Signs data using HMAC-SHA256.
56 | * @param key - The CryptoKey for signing.
57 | * @param data - The string data to sign.
58 | * @returns A promise resolving to the signature as a hex string.
59 | */
60 | async function signData(key: CryptoKey, data: string): Promise<string> {
61 | const enc = new TextEncoder()
62 | const signatureBuffer = await crypto.subtle.sign('HMAC', key, enc.encode(data))
63 | // Convert ArrayBuffer to hex string
64 | return Array.from(new Uint8Array(signatureBuffer))
65 | .map((b) => b.toString(16).padStart(2, '0'))
66 | .join('')
67 | }
68 |
69 | /**
70 | * Verifies an HMAC-SHA256 signature.
71 | * @param key - The CryptoKey for verification.
72 | * @param signatureHex - The signature to verify (hex string).
73 | * @param data - The original data that was signed.
74 | * @returns A promise resolving to true if the signature is valid, false otherwise.
75 | */
76 | async function verifySignature(
77 | key: CryptoKey,
78 | signatureHex: string,
79 | data: string
80 | ): Promise<boolean> {
81 | const enc = new TextEncoder()
82 | try {
83 | const signatureBytes = new Uint8Array(
84 | signatureHex.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16))
85 | )
86 | return await crypto.subtle.verify('HMAC', key, signatureBytes.buffer, enc.encode(data))
87 | } catch (e) {
88 | console.error('Error verifying signature:', e)
89 | return false
90 | }
91 | }
92 |
93 | /**
94 | * Parses the signed cookie and verifies its integrity.
95 | * @param cookieHeader - The value of the Cookie header from the request.
96 | * @param secret - The secret key used for signing.
97 | * @returns A promise resolving to the list of approved client IDs if the cookie is valid, otherwise null.
98 | */
99 | async function getApprovedClientsFromCookie(
100 | cookieHeader: string | null,
101 | secret: string
102 | ): Promise<string[] | null> {
103 | if (!cookieHeader) return null
104 |
105 | const cookies = cookieHeader.split(';').map((c) => c.trim())
106 | const targetCookie = cookies.find((c) => c.startsWith(`${COOKIE_NAME}=`))
107 |
108 | if (!targetCookie) return null
109 |
110 | const cookieValue = targetCookie.substring(COOKIE_NAME.length + 1)
111 | const parts = cookieValue.split('.')
112 |
113 | if (parts.length !== 2) {
114 | console.warn('Invalid cookie format received.')
115 | return null // Invalid format
116 | }
117 |
118 | const [signatureHex, base64Payload] = parts
119 | const payload = atob(base64Payload) // Assuming payload is base64 encoded JSON string
120 |
121 | const key = await importKey(secret)
122 | const isValid = await verifySignature(key, signatureHex, payload)
123 |
124 | if (!isValid) {
125 | console.warn('Cookie signature verification failed.')
126 | return null // Signature invalid
127 | }
128 |
129 | try {
130 | const approvedClients = JSON.parse(payload)
131 | if (!Array.isArray(approvedClients)) {
132 | console.warn('Cookie payload is not an array.')
133 | return null // Payload isn't an array
134 | }
135 | // Ensure all elements are strings
136 | if (!approvedClients.every((item) => typeof item === 'string')) {
137 | console.warn('Cookie payload contains non-string elements.')
138 | return null
139 | }
140 | return approvedClients as string[]
141 | } catch (e) {
142 | console.error('Error parsing cookie payload:', e)
143 | return null // JSON parsing failed
144 | }
145 | }
146 |
147 | /**
148 | * Checks if a given client ID has already been approved by the user,
149 | * based on a signed cookie.
150 | *
151 | * @param request - The incoming Request object to read cookies from.
152 | * @param clientId - The OAuth client ID to check approval for.
153 | * @param cookieSecret - The secret key used to sign/verify the approval cookie.
154 | * @returns A promise resolving to true if the client ID is in the list of approved clients in a valid cookie, false otherwise.
155 | */
156 | export async function clientIdAlreadyApproved(
157 | request: Request,
158 | clientId: string,
159 | cookieSecret: string
160 | ): Promise<boolean> {
161 | if (!clientId) return false
162 | const cookieHeader = request.headers.get('Cookie')
163 | const approvedClients = await getApprovedClientsFromCookie(cookieHeader, cookieSecret)
164 |
165 | return approvedClients?.includes(clientId) ?? false
166 | }
167 |
168 | /**
169 | * Configuration for the approval dialog
170 | */
171 | export interface ApprovalDialogOptions {
172 | /**
173 | * Client information to display in the approval dialog
174 | */
175 | client: ClientInfo | null
176 | /**
177 | * Server information to display in the approval dialog
178 | */
179 | server: {
180 | name: string
181 | logo?: string
182 | description?: string
183 | }
184 | /**
185 | * Arbitrary state data to pass through the approval flow
186 | * Will be encoded in the form and returned when approval is complete
187 | */
188 | state: Record<string, any>
189 | /**
190 | * CSRF token to include in the approval form
191 | */
192 | csrfToken: string
193 | /**
194 | * Set-Cookie header to include in the approval response
195 | */
196 | setCookie: string
197 | }
198 |
199 | /**
200 | * Renders an approval dialog for OAuth authorization
201 | * The dialog displays information about the client and server
202 | * and includes a form to submit approval
203 | *
204 | * @param request - The HTTP request
205 | * @param options - Configuration for the approval dialog
206 | * @returns A Response containing the HTML approval dialog
207 | */
208 | export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response {
209 | const { client, server, state, csrfToken, setCookie } = options
210 | const encodedState = btoa(JSON.stringify(state))
211 |
212 | const serverName = sanitizeHtml(server.name)
213 | const clientName = client?.clientName ? sanitizeHtml(client.clientName) : 'Unknown MCP Client'
214 | const serverDescription = server.description ? sanitizeHtml(server.description) : ''
215 |
216 | const logoUrl = server.logo ? sanitizeHtml(server.logo) : ''
217 | const clientUri = client?.clientUri ? sanitizeHtml(client.clientUri) : ''
218 | const policyUri = client?.policyUri ? sanitizeHtml(client.policyUri) : ''
219 | const tosUri = client?.tosUri ? sanitizeHtml(client.tosUri) : ''
220 |
221 | const contacts =
222 | client?.contacts && client.contacts.length > 0 ? sanitizeHtml(client.contacts.join(', ')) : ''
223 |
224 | const redirectUris =
225 | client?.redirectUris && client.redirectUris.length > 0
226 | ? client.redirectUris.map((uri) => sanitizeHtml(uri)).filter((uri) => uri !== '')
227 | : []
228 |
229 | const htmlContent = `
230 | <!DOCTYPE html>
231 | <html lang="en">
232 | <head>
233 | <meta charset="UTF-8">
234 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
235 | <title>${clientName} | Authorization Request</title>
236 | <style>
237 | /* Modern, responsive styling with system fonts */
238 | :root {
239 | --primary-color: #0070f3;
240 | --error-color: #f44336;
241 | --border-color: #e5e7eb;
242 | --text-color: #333;
243 | --background-color: #fff;
244 | --card-shadow: 0 8px 36px 8px rgba(0, 0, 0, 0.1);
245 | }
246 |
247 | body {
248 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
249 | Helvetica, Arial, sans-serif, "Apple Color Emoji",
250 | "Segoe UI Emoji", "Segoe UI Symbol";
251 | line-height: 1.6;
252 | color: var(--text-color);
253 | background-color: #f9fafb;
254 | margin: 0;
255 | padding: 0;
256 | }
257 |
258 | .container {
259 | max-width: 600px;
260 | margin: 2rem auto;
261 | padding: 1rem;
262 | }
263 |
264 | .precard {
265 | padding: 2rem;
266 | text-align: center;
267 | }
268 |
269 | .card {
270 | background-color: var(--background-color);
271 | border-radius: 8px;
272 | box-shadow: var(--card-shadow);
273 | padding: 2rem;
274 | }
275 |
276 | .header {
277 | display: flex;
278 | align-items: center;
279 | justify-content: center;
280 | margin-bottom: 1.5rem;
281 | }
282 |
283 | .logo {
284 | width: 48px;
285 | height: 48px;
286 | margin-right: 1rem;
287 | border-radius: 8px;
288 | object-fit: contain;
289 | }
290 |
291 | .title {
292 | margin: 0;
293 | font-size: 1.3rem;
294 | font-weight: 400;
295 | }
296 |
297 | .alert {
298 | margin: 0;
299 | font-size: 1.5rem;
300 | font-weight: 400;
301 | margin: 1rem 0;
302 | text-align: center;
303 | }
304 |
305 | .description {
306 | color: #555;
307 | }
308 |
309 | .client-info {
310 | border: 1px solid var(--border-color);
311 | border-radius: 6px;
312 | padding: 1rem 1rem 0.5rem;
313 | margin-bottom: 1.5rem;
314 | }
315 |
316 | .client-name {
317 | font-weight: 600;
318 | font-size: 1.2rem;
319 | margin: 0 0 0.5rem 0;
320 | }
321 |
322 | .client-detail {
323 | display: flex;
324 | margin-bottom: 0.5rem;
325 | align-items: baseline;
326 | }
327 |
328 | .detail-label {
329 | font-weight: 500;
330 | min-width: 120px;
331 | }
332 |
333 | .detail-value {
334 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
335 | word-break: break-all;
336 | }
337 |
338 | .detail-value a {
339 | color: inherit;
340 | text-decoration: underline;
341 | }
342 |
343 | .detail-value.small {
344 | font-size: 0.8em;
345 | }
346 |
347 | .external-link-icon {
348 | font-size: 0.75em;
349 | margin-left: 0.25rem;
350 | vertical-align: super;
351 | }
352 |
353 | .actions {
354 | display: flex;
355 | justify-content: flex-end;
356 | gap: 1rem;
357 | margin-top: 2rem;
358 | }
359 |
360 | .button {
361 | padding: 0.75rem 1.5rem;
362 | border-radius: 6px;
363 | font-weight: 500;
364 | cursor: pointer;
365 | border: none;
366 | font-size: 1rem;
367 | }
368 |
369 | .button-primary {
370 | background-color: var(--primary-color);
371 | color: white;
372 | }
373 |
374 | .button-secondary {
375 | background-color: transparent;
376 | border: 1px solid var(--border-color);
377 | color: var(--text-color);
378 | }
379 |
380 | /* Responsive adjustments */
381 | @media (max-width: 640px) {
382 | .container {
383 | margin: 1rem auto;
384 | padding: 0.5rem;
385 | }
386 |
387 | .card {
388 | padding: 1.5rem;
389 | }
390 |
391 | .client-detail {
392 | flex-direction: column;
393 | }
394 |
395 | .detail-label {
396 | min-width: unset;
397 | margin-bottom: 0.25rem;
398 | }
399 |
400 | .actions {
401 | flex-direction: column;
402 | }
403 |
404 | .button {
405 | width: 100%;
406 | }
407 | }
408 | </style>
409 | </head>
410 | <body>
411 | <div class="container">
412 | <div class="precard">
413 | <div class="header">
414 | ${logoUrl ? `<img src="${logoUrl}" alt="${serverName} Logo" class="logo">` : ''}
415 | <h1 class="title"><strong>${serverName}</strong></h1>
416 | </div>
417 |
418 | ${serverDescription ? `<p class="description">${serverDescription}</p>` : ''}
419 | </div>
420 |
421 | <div class="card">
422 |
423 | <h2 class="alert"><strong>${clientName || 'A new MCP Client'}</strong> is requesting access</h1>
424 |
425 | <div class="client-info">
426 | <div class="client-detail">
427 | <div class="detail-label">Name:</div>
428 | <div class="detail-value">
429 | ${clientName}
430 | </div>
431 | </div>
432 |
433 | ${
434 | clientUri
435 | ? `
436 | <div class="client-detail">
437 | <div class="detail-label">Website:</div>
438 | <div class="detail-value small">
439 | <a href="${clientUri}" target="_blank" rel="noopener noreferrer">
440 | ${clientUri}
441 | </a>
442 | </div>
443 | </div>
444 | `
445 | : ''
446 | }
447 |
448 | ${
449 | policyUri
450 | ? `
451 | <div class="client-detail">
452 | <div class="detail-label">Privacy Policy:</div>
453 | <div class="detail-value">
454 | <a href="${policyUri}" target="_blank" rel="noopener noreferrer">
455 | ${policyUri}
456 | </a>
457 | </div>
458 | </div>
459 | `
460 | : ''
461 | }
462 |
463 | ${
464 | tosUri
465 | ? `
466 | <div class="client-detail">
467 | <div class="detail-label">Terms of Service:</div>
468 | <div class="detail-value">
469 | <a href="${tosUri}" target="_blank" rel="noopener noreferrer">
470 | ${tosUri}
471 | </a>
472 | </div>
473 | </div>
474 | `
475 | : ''
476 | }
477 |
478 | ${
479 | redirectUris.length > 0
480 | ? `
481 | <div class="client-detail">
482 | <div class="detail-label">Redirect URIs:</div>
483 | <div class="detail-value small">
484 | ${redirectUris.map((uri) => `<div>${uri}</div>`).join('')}
485 | </div>
486 | </div>
487 | `
488 | : ''
489 | }
490 |
491 | ${
492 | contacts
493 | ? `
494 | <div class="client-detail">
495 | <div class="detail-label">Contact:</div>
496 | <div class="detail-value">${contacts}</div>
497 | </div>
498 | `
499 | : ''
500 | }
501 | </div>
502 |
503 | <p>This MCP Client is requesting to be authorized on ${serverName}. If you approve, you will be redirected to complete authentication.</p>
504 |
505 | <form method="post" action="${new URL(request.url).pathname}">
506 | <input type="hidden" name="state" value="${encodedState}">
507 | <input type="hidden" name="csrf_token" value="${csrfToken}">
508 |
509 | <div class="actions">
510 | <button type="button" class="button button-secondary" onclick="window.history.back()">Cancel</button>
511 | <button type="submit" class="button button-primary">Approve</button>
512 | </div>
513 | </form>
514 | </div>
515 | </div>
516 | </body>
517 | </html>
518 | `
519 |
520 | return new Response(htmlContent, {
521 | headers: {
522 | 'Content-Security-Policy': "frame-ancestors 'none'",
523 | 'Content-Type': 'text/html; charset=utf-8',
524 | 'Set-Cookie': setCookie,
525 | 'X-Frame-Options': 'DENY',
526 | },
527 | })
528 | }
529 |
530 | /**
531 | * Result of parsing the approval form submission.
532 | */
533 | export interface ParsedApprovalResult {
534 | /** The original state object containing the OAuth request information. */
535 | state: { oauthReqInfo?: AuthRequest }
536 | /** Headers to set on the redirect response, including the Set-Cookie header. */
537 | headers: Record<string, string>
538 | }
539 |
540 | /**
541 | * Parses the form submission from the approval dialog, extracts the state,
542 | * and generates Set-Cookie headers to mark the client as approved.
543 | *
544 | * @param request - The incoming POST Request object containing the form data.
545 | * @param cookieSecret - The secret key used to sign the approval cookie.
546 | * @returns A promise resolving to an object containing the parsed state and necessary headers.
547 | * @throws If the request method is not POST, form data is invalid, or state is missing.
548 | */
549 | export async function parseRedirectApproval(
550 | request: Request,
551 | cookieSecret: string
552 | ): Promise<ParsedApprovalResult> {
553 | if (request.method !== 'POST') {
554 | throw new Error('Invalid request method. Expected POST.')
555 | }
556 |
557 | const formData = await request.formData()
558 |
559 | const tokenFromForm = formData.get('csrf_token')
560 | if (!tokenFromForm || typeof tokenFromForm !== 'string') {
561 | throw new Error('Missing CSRF token in form data')
562 | }
563 |
564 | const cookieHeader = request.headers.get('Cookie') || ''
565 | const cookies = cookieHeader.split(';').map((c) => c.trim())
566 | const csrfCookie = cookies.find((c) => c.startsWith('__Host-CSRF_TOKEN='))
567 | const tokenFromCookie = csrfCookie ? csrfCookie.substring('__Host-CSRF_TOKEN='.length) : null
568 |
569 | if (!tokenFromCookie || tokenFromForm !== tokenFromCookie) {
570 | throw new Error('CSRF token mismatch')
571 | }
572 |
573 | const encodedState = formData.get('state')
574 | if (!encodedState || typeof encodedState !== 'string') {
575 | throw new Error('Missing state in form data')
576 | }
577 |
578 | const state = JSON.parse(atob(encodedState))
579 | if (!state.oauthReqInfo || !state.oauthReqInfo.clientId) {
580 | throw new Error('Invalid state data')
581 | }
582 |
583 | const existingApprovedClients =
584 | (await getApprovedClientsFromCookie(request.headers.get('Cookie'), cookieSecret)) || []
585 | const updatedApprovedClients = Array.from(
586 | new Set([...existingApprovedClients, state.oauthReqInfo.clientId])
587 | )
588 |
589 | const payload = JSON.stringify(updatedApprovedClients)
590 | const key = await importKey(cookieSecret)
591 | const signature = await signData(key, payload)
592 | const newCookieValue = `${signature}.${btoa(payload)}` // signature.base64(payload)
593 |
594 | const headers: Record<string, string> = {
595 | 'Set-Cookie': `${COOKIE_NAME}=${newCookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=${ONE_YEAR_IN_SECONDS}`,
596 | }
597 |
598 | return { headers, state }
599 | }
600 |
601 | /**
602 | * Result from bindStateToSession containing the cookie to set
603 | */
604 | export interface BindStateResult {
605 | /**
606 | * Set-Cookie header value to bind the state to the user's session
607 | */
608 | setCookie: string
609 | }
610 |
611 | /**
612 | * Result from validateOAuthState containing the original OAuth request info and cookie to clear
613 | */
614 | export interface ValidateStateResult {
615 | /**
616 | * The original OAuth request information that was stored with the state token
617 | */
618 | oauthReqInfo: AuthRequest
619 |
620 | /**
621 | * The PKCE code verifier retrieved from server-side storage (never transmitted to client)
622 | */
623 | codeVerifier: string
624 |
625 | /**
626 | * Set-Cookie header value to clear the state cookie
627 | */
628 | clearCookie: string
629 | }
630 |
631 | export function generateCSRFProtection(): { token: string; setCookie: string } {
632 | const token = crypto.randomUUID()
633 | const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`
634 | return { token, setCookie }
635 | }
636 |
637 | export async function createOAuthState(
638 | oauthReqInfo: AuthRequest,
639 | kv: KVNamespace,
640 | codeVerifier: string
641 | ): Promise<string> {
642 | const stateToken = crypto.randomUUID()
643 | const stateData = { oauthReqInfo, codeVerifier } satisfies {
644 | oauthReqInfo: AuthRequest
645 | codeVerifier: string
646 | }
647 |
648 | await kv.put(`oauth:state:${stateToken}`, JSON.stringify(stateData), {
649 | expirationTtl: 600,
650 | })
651 | return stateToken
652 | }
653 |
654 | /**
655 | * Binds an OAuth state token to the user's browser session using a secure cookie.
656 | *
657 | * @param stateToken - The state token to bind to the session
658 | * @returns Object containing the Set-Cookie header to send to the client
659 | */
660 | export async function bindStateToSession(stateToken: string): Promise<BindStateResult> {
661 | const consentedStateCookieName = '__Host-CONSENTED_STATE'
662 |
663 | // Hash the state token to provide defense-in-depth
664 | const encoder = new TextEncoder()
665 | const data = encoder.encode(stateToken)
666 | const hashBuffer = await crypto.subtle.digest('SHA-256', data)
667 | const hashArray = Array.from(new Uint8Array(hashBuffer))
668 | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
669 |
670 | const setCookie = `${consentedStateCookieName}=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`
671 |
672 | return { setCookie }
673 | }
674 |
675 | /**
676 | * Validates OAuth state from the request, ensuring:
677 | * 1. The state parameter exists in KV (proves it was created by our server)
678 | * 2. The state hash matches the session cookie (proves this browser consented to it)
679 | *
680 | * This prevents attacks where an attacker's valid state token is injected into
681 | * a victim's OAuth flow.
682 | *
683 | * @param request - The HTTP request containing state parameter and cookies
684 | * @param kv - Cloudflare KV namespace for storing OAuth state data
685 | * @returns Object containing the original OAuth request info and cookie to clear
686 | * @throws If state is missing, mismatched, or expired
687 | */
688 | export async function validateOAuthState(
689 | request: Request,
690 | kv: KVNamespace
691 | ): Promise<ValidateStateResult> {
692 | const consentedStateCookieName = '__Host-CONSENTED_STATE'
693 | const url = new URL(request.url)
694 | const stateFromQuery = url.searchParams.get('state')
695 |
696 | if (!stateFromQuery) {
697 | throw new Error('Missing state parameter')
698 | }
699 |
700 | // Decode the state parameter to extract the embedded stateToken
701 | let stateToken: string
702 | try {
703 | const decodedState = JSON.parse(atob(stateFromQuery))
704 | stateToken = decodedState.state
705 | if (!stateToken) {
706 | throw new Error('State token not found in decoded state')
707 | }
708 | } catch (e) {
709 | throw new Error('Failed to decode state parameter')
710 | }
711 |
712 | const storedDataJson = await kv.get(`oauth:state:${stateToken}`)
713 | if (!storedDataJson) {
714 | throw new Error('Invalid or expired state')
715 | }
716 |
717 | const cookieHeader = request.headers.get('Cookie') || ''
718 | const cookies = cookieHeader.split(';').map((c) => c.trim())
719 | const consentedStateCookie = cookies.find((c) => c.startsWith(`${consentedStateCookieName}=`))
720 | const consentedStateHash = consentedStateCookie
721 | ? consentedStateCookie.substring(consentedStateCookieName.length + 1)
722 | : null
723 |
724 | if (!consentedStateHash) {
725 | throw new Error('Missing session binding cookie - authorization flow must be restarted')
726 | }
727 |
728 | const encoder = new TextEncoder()
729 | const data = encoder.encode(stateToken)
730 | const hashBuffer = await crypto.subtle.digest('SHA-256', data)
731 | const hashArray = Array.from(new Uint8Array(hashBuffer))
732 | const stateHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
733 |
734 | if (stateHash !== consentedStateHash) {
735 | throw new Error('State token does not match session - possible CSRF attack detected')
736 | }
737 |
738 | // Parse and validate stored OAuth state data
739 | const StoredOAuthStateSchema = z.object({
740 | oauthReqInfo: z
741 | .object({
742 | clientId: z.string(),
743 | scope: z.array(z.string()),
744 | state: z.string(),
745 | responseType: z.string(),
746 | redirectUri: z.string(),
747 | })
748 | .passthrough(), // preserve any other fields from oauth-provider
749 | codeVerifier: z.string().min(1), // Our code verifier for Cloudflare OAuth
750 | })
751 |
752 | const parseResult = StoredOAuthStateSchema.safeParse(JSON.parse(storedDataJson))
753 | if (!parseResult.success) {
754 | throw new Error('Invalid OAuth state data format - PKCE security violation')
755 | }
756 |
757 | await kv.delete(`oauth:state:${stateToken}`)
758 | const clearCookie = `${consentedStateCookieName}=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`
759 |
760 | return {
761 | oauthReqInfo: parseResult.data.oauthReqInfo,
762 | codeVerifier: parseResult.data.codeVerifier,
763 | clearCookie,
764 | }
765 | }
766 |
767 | /**
768 | * Sanitizes HTML content to prevent XSS attacks
769 | * @param unsafe - The unsafe string that might contain HTML
770 | * @returns A safe string with HTML special characters escaped
771 | */
772 | function sanitizeHtml(unsafe: string): string {
773 | return unsafe
774 | .replace(/&/g, '&')
775 | .replace(/</g, '<')
776 | .replace(/>/g, '>')
777 | .replace(/"/g, '"')
778 | .replace(/'/g, ''')
779 | }
780 |
```
--------------------------------------------------------------------------------
/apps/graphql/src/tools/graphql.tools.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as LZString from 'lz-string'
2 | import { z } from 'zod'
3 |
4 | import { getProps } from '@repo/mcp-common/src/get-props'
5 |
6 | import type { GraphQLMCP } from '../graphql.app'
7 |
8 | // GraphQL API endpoint
9 | const CLOUDFLARE_GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql'
10 |
11 | // Type definitions for GraphQL schema responses
12 | interface GraphQLTypeRef {
13 | kind: string
14 | name: string | null
15 | ofType?: GraphQLTypeRef | null
16 | }
17 |
18 | interface GraphQLField {
19 | name: string
20 | description: string | null
21 | args: Array<{
22 | name: string
23 | description: string | null
24 | type: GraphQLTypeRef
25 | }>
26 | type: GraphQLTypeRef
27 | }
28 |
29 | interface GraphQLType {
30 | name: string
31 | kind: string
32 | description: string | null
33 | fields?: GraphQLField[] | null
34 | inputFields?: Array<{
35 | name: string
36 | description: string | null
37 | type: GraphQLTypeRef
38 | }> | null
39 | interfaces?: Array<{ name: string }> | null
40 | enumValues?: Array<{
41 | name: string
42 | description: string | null
43 | }> | null
44 | possibleTypes?: Array<{ name: string }> | null
45 | }
46 |
47 | interface SchemaOverviewResponse {
48 | data: {
49 | __schema: {
50 | queryType: { name: string } | null
51 | mutationType: { name: string } | null
52 | subscriptionType: { name: string } | null
53 | types: Array<{
54 | name: string
55 | kind: string
56 | description: string | null
57 | }>
58 | }
59 | }
60 | }
61 |
62 | interface TypeDetailsResponse {
63 | data: {
64 | __type: GraphQLType
65 | }
66 | }
67 |
68 | // Define the structure of a single error
69 | const graphQLErrorSchema = z.object({
70 | message: z.string(),
71 | path: z.array(z.union([z.string(), z.number()])),
72 | extensions: z.object({
73 | code: z.string(),
74 | timestamp: z.string(),
75 | ray_id: z.string(),
76 | }),
77 | })
78 |
79 | // Define the overall GraphQL response schema
80 | const graphQLResponseSchema = z.object({
81 | data: z.union([z.record(z.unknown()), z.null()]),
82 | errors: z.union([z.array(graphQLErrorSchema), z.null()]),
83 | })
84 |
85 | /**
86 | * Fetches the high-level overview of the GraphQL schema
87 | * @param apiToken Cloudflare API token
88 | * @returns Basic schema structure
89 | */
90 | async function fetchSchemaOverview(apiToken: string): Promise<SchemaOverviewResponse> {
91 | const overviewQuery = `
92 | query SchemaOverview {
93 | __schema {
94 | queryType { name }
95 | mutationType { name }
96 | subscriptionType { name }
97 | types {
98 | name
99 | kind
100 | description
101 | }
102 | }
103 | }
104 | `
105 |
106 | const response = await executeGraphQLRequest<SchemaOverviewResponse>(overviewQuery, apiToken)
107 | return response
108 | }
109 |
110 | /**
111 | * Fetches detailed information about a specific GraphQL type
112 | * @param typeName The name of the type to fetch details for
113 | * @param apiToken Cloudflare API token
114 | * @returns Detailed type information
115 | */
116 | async function fetchTypeDetails(typeName: string, apiToken: string): Promise<TypeDetailsResponse> {
117 | const typeDetailsQuery = `
118 | query TypeDetails {
119 | __type(name: "${typeName}") {
120 | name
121 | kind
122 | description
123 | fields(includeDeprecated: false) {
124 | name
125 | description
126 | args {
127 | name
128 | description
129 | type {
130 | kind
131 | name
132 | ofType {
133 | kind
134 | name
135 | }
136 | }
137 | }
138 | type {
139 | kind
140 | name
141 | ofType {
142 | kind
143 | name
144 | ofType {
145 | kind
146 | name
147 | }
148 | }
149 | }
150 | }
151 | inputFields {
152 | name
153 | description
154 | type {
155 | kind
156 | name
157 | ofType {
158 | kind
159 | name
160 | }
161 | }
162 | }
163 | interfaces {
164 | name
165 | }
166 | enumValues(includeDeprecated: false) {
167 | name
168 | description
169 | }
170 | possibleTypes {
171 | name
172 | }
173 | }
174 | }
175 | `
176 |
177 | const response = await executeGraphQLRequest<TypeDetailsResponse>(typeDetailsQuery, apiToken)
178 | return response
179 | }
180 |
181 | /**
182 | * Helper function to execute GraphQL requests
183 | * @param query GraphQL query to execute
184 | * @param apiToken Cloudflare API token
185 | * @returns Response data
186 | */
187 | async function executeGraphQLRequest<T>(query: string, apiToken: string): Promise<T> {
188 | const response = await fetch(CLOUDFLARE_GRAPHQL_ENDPOINT, {
189 | method: 'POST',
190 | headers: {
191 | 'Content-Type': 'application/json',
192 | Authorization: `Bearer ${apiToken}`,
193 | },
194 | body: JSON.stringify({ query }),
195 | })
196 |
197 | if (!response.ok) {
198 | throw new Error(`Failed to execute GraphQL request: ${response.statusText}`)
199 | }
200 |
201 | const data = graphQLResponseSchema.parse(await response.json())
202 |
203 | // Check for GraphQL errors in the response
204 | if (data && data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
205 | const errorMessages = data.errors.map((e: { message: string }) => e.message).join(', ')
206 | console.warn(`GraphQL errors: ${errorMessages}`)
207 |
208 | // If the error is about mutations not being supported, we can handle it gracefully
209 | if (errorMessages.includes('Mutations are not supported')) {
210 | console.info('Mutations are not supported by the Cloudflare GraphQL API')
211 | }
212 | }
213 |
214 | return data as T
215 | }
216 |
217 | /**
218 | * Executes a GraphQL query against Cloudflare's API
219 | * @param query The GraphQL query to execute
220 | * @param variables Variables for the query
221 | * @param apiToken Cloudflare API token
222 | * @returns The query results
223 | */
224 | async function executeGraphQLQuery(query: string, variables: any, apiToken: string) {
225 | // Clone the variables to avoid modifying the original
226 | const queryVariables = { ...variables }
227 |
228 | const response = await fetch(CLOUDFLARE_GRAPHQL_ENDPOINT, {
229 | method: 'POST',
230 | headers: {
231 | 'Content-Type': 'application/json',
232 | Authorization: `Bearer ${apiToken}`,
233 | },
234 | body: JSON.stringify({
235 | query,
236 | variables: queryVariables,
237 | }),
238 | })
239 |
240 | if (!response.ok) {
241 | throw new Error(`Failed to execute GraphQL query: ${response.statusText}`)
242 | }
243 |
244 | const result = graphQLResponseSchema.parse(await response.json())
245 |
246 | // Check for GraphQL errors in the response
247 | if (result && result.errors && Array.isArray(result.errors) && result.errors.length > 0) {
248 | const errorMessages = result.errors.map((e: { message: string }) => e.message).join(', ')
249 | console.warn(`GraphQL query errors: ${errorMessages}`)
250 | }
251 |
252 | return result
253 | }
254 |
255 | /**
256 | * Searches for matching types and fields in a GraphQL schema
257 | * @param schema The GraphQL schema to search
258 | * @param keyword The keyword to search for
259 | * @param typeDetails Optional map of type details for deeper searching
260 | * @returns Matching types and fields
261 | */
262 | async function searchGraphQLSchema(
263 | schema: SchemaOverviewResponse,
264 | keyword: string,
265 | accountId: string,
266 | apiToken: string,
267 | maxDetailsToFetch: number = 10,
268 | onlyObjectTypes: boolean = true
269 | ) {
270 | const normalizedKeyword = keyword.toLowerCase()
271 | const results = {
272 | types: [] as Array<{
273 | name: string
274 | kind: string
275 | description: string | null
276 | matchReason: string
277 | }>,
278 | fields: [] as Array<{
279 | typeName: string
280 | fieldName: string
281 | description: string | null
282 | matchReason: string
283 | }>,
284 | enumValues: [] as Array<{
285 | typeName: string
286 | enumValue: string
287 | description: string | null
288 | matchReason: string
289 | }>,
290 | args: [] as Array<{
291 | typeName: string
292 | fieldName: string
293 | argName: string
294 | description: string | null
295 | matchReason: string
296 | }>,
297 | }
298 |
299 | // First pass: Search through type names and descriptions
300 | const matchingTypeNames: string[] = []
301 |
302 | for (const type of schema.data.__schema.types || []) {
303 | // Skip internal types (those starting with __)
304 | if (type.name?.startsWith('__')) continue
305 |
306 | // Check if type name or description matches
307 | if (type.name?.toLowerCase().includes(normalizedKeyword)) {
308 | results.types.push({
309 | ...type,
310 | matchReason: `Type name contains "${keyword}"`,
311 | })
312 | matchingTypeNames.push(type.name)
313 | } else if (type.description?.toLowerCase().includes(normalizedKeyword)) {
314 | results.types.push({
315 | ...type,
316 | matchReason: `Type description contains "${keyword}"`,
317 | })
318 | matchingTypeNames.push(type.name)
319 | }
320 | }
321 |
322 | // Second pass: For potentially relevant types, fetch details and search deeper
323 | // Start with matching types, then add important schema types if we have capacity
324 | let typesToExamine = [...matchingTypeNames]
325 |
326 | // Add root operation types if they're not already included
327 | const rootTypes = [
328 | schema.data.__schema.queryType?.name,
329 | schema.data.__schema.mutationType?.name,
330 | schema.data.__schema.subscriptionType?.name,
331 | ].filter(Boolean) as string[]
332 |
333 | for (const rootType of rootTypes) {
334 | if (!typesToExamine.includes(rootType)) {
335 | typesToExamine.push(rootType)
336 | }
337 | }
338 |
339 | // Add object types that might contain relevant fields
340 | const objectTypes = schema.data.__schema.types
341 | .filter((t) => {
342 | // If onlyObjectTypes is true, only include OBJECT types
343 | if (onlyObjectTypes) {
344 | return t.kind === 'OBJECT' && !t.name.startsWith('__')
345 | }
346 | // Otherwise include both OBJECT and INTERFACE types
347 | return (t.kind === 'OBJECT' || t.kind === 'INTERFACE') && !t.name.startsWith('__')
348 | })
349 | .map((t) => t.name)
350 |
351 | // Combine all potential types to examine, but limit to a reasonable number
352 | typesToExamine = [...new Set([...typesToExamine, ...objectTypes])].slice(0, maxDetailsToFetch)
353 |
354 | // Fetch details for these types and search through their fields
355 | for (const typeName of typesToExamine) {
356 | try {
357 | const typeDetails = await fetchTypeDetails(typeName, apiToken)
358 | const type = typeDetails.data.__type
359 |
360 | if (!type) continue
361 |
362 | // Search through fields
363 | if (type.fields) {
364 | for (const field of type.fields) {
365 | // Check if field name or description matches
366 | if (field.name.toLowerCase().includes(normalizedKeyword)) {
367 | results.fields.push({
368 | typeName: type.name,
369 | fieldName: field.name,
370 | description: field.description,
371 | matchReason: `Field name contains "${keyword}"`,
372 | })
373 | } else if (field.description?.toLowerCase().includes(normalizedKeyword)) {
374 | results.fields.push({
375 | typeName: type.name,
376 | fieldName: field.name,
377 | description: field.description,
378 | matchReason: `Field description contains "${keyword}"`,
379 | })
380 | }
381 |
382 | // Search through field arguments
383 | if (field.args) {
384 | for (const arg of field.args) {
385 | if (arg.name.toLowerCase().includes(normalizedKeyword)) {
386 | results.args.push({
387 | typeName: type.name,
388 | fieldName: field.name,
389 | argName: arg.name,
390 | description: arg.description,
391 | matchReason: `Argument name contains "${keyword}"`,
392 | })
393 | } else if (arg.description?.toLowerCase().includes(normalizedKeyword)) {
394 | results.args.push({
395 | typeName: type.name,
396 | fieldName: field.name,
397 | argName: arg.name,
398 | description: arg.description,
399 | matchReason: `Argument description contains "${keyword}"`,
400 | })
401 | }
402 | }
403 | }
404 | }
405 | }
406 |
407 | // Search through enum values
408 | if (type.enumValues) {
409 | for (const enumValue of type.enumValues) {
410 | if (enumValue.name.toLowerCase().includes(normalizedKeyword)) {
411 | results.enumValues.push({
412 | typeName: type.name,
413 | enumValue: enumValue.name,
414 | description: enumValue.description,
415 | matchReason: `Enum value contains "${keyword}"`,
416 | })
417 | } else if (enumValue.description?.toLowerCase().includes(normalizedKeyword)) {
418 | results.enumValues.push({
419 | typeName: type.name,
420 | enumValue: enumValue.name,
421 | description: enumValue.description,
422 | matchReason: `Enum value description contains "${keyword}"`,
423 | })
424 | }
425 | }
426 | }
427 | } catch (error) {
428 | console.error(`Error fetching details for type ${typeName}:`, error)
429 | }
430 | }
431 |
432 | return results
433 | }
434 |
435 | /**
436 | * Registers GraphQL tools with the MCP server
437 | * @param agent The MCP agent instance
438 | */
439 | export function registerGraphQLTools(agent: GraphQLMCP) {
440 | // Tool to search the GraphQL schema for types, fields, and enum values matching a keyword
441 | agent.server.tool(
442 | 'graphql_schema_search',
443 | `Search the Cloudflare GraphQL API schema for types, fields, and enum values matching a keyword
444 |
445 | Use this tool when:
446 |
447 | - You are unsure which dataset to use for your query.
448 | - A user is looking for specific types, fields, or enum values in the Cloudflare GraphQL API schema.
449 |
450 | IMPORTANT GUIDELINES:
451 | - DO NOT query for dimensions unless the user explicitly asked to group by or show dimensions.
452 | - Only include fields that the user specifically requested in their query.
453 | - Keep queries as simple as possible while fulfilling the user's request.
454 |
455 | Workflow:
456 | 1. Use this tool to search for dataset types by keyword.
457 | 2. When a relevant dataset type is found, immediately use graphql_schema_details to get the complete structure of that dataset.
458 | 3. After understanding the schema structure, proceed directly to constructing and executing queries using the graphql_query tool.
459 | 4. Do not use graphql_schema_overview or graphql_complete_schema after finding the relevant dataset - these are redundant steps.
460 |
461 | This tool searches the Cloudflare GraphQL API schema for any schema elements (such as object types, field names, or enum options) that match a given keyword. It returns schema fragments and definitions to assist in constructing valid and precise GraphQL queries.
462 | `,
463 | {
464 | keyword: z.string().describe('The keyword to search for in the schema'),
465 | maxDetailsToFetch: z
466 | .number()
467 | .min(1)
468 | .max(50)
469 | .default(10)
470 | .describe('Maximum number of types to fetch details for'),
471 | includeInternalTypes: z
472 | .boolean()
473 | .default(false)
474 | .describe(
475 | 'Whether to include internal types (those starting with __) in the search results'
476 | ),
477 | onlyObjectTypes: z
478 | .boolean()
479 | .default(true)
480 | .describe(
481 | 'Whether to only include OBJECT kind types in the search results with descriptions'
482 | ),
483 | },
484 | async (params) => {
485 | const {
486 | keyword,
487 | maxDetailsToFetch = 10,
488 | includeInternalTypes = false,
489 | onlyObjectTypes = true,
490 | } = params
491 | const accountId = await agent.getActiveAccountId()
492 | if (!accountId) {
493 | return {
494 | content: [
495 | {
496 | type: 'text',
497 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
498 | },
499 | ],
500 | }
501 | }
502 |
503 | try {
504 | const props = getProps(agent)
505 | // First fetch the schema overview
506 | const schemaOverview = await fetchSchemaOverview(props.accessToken)
507 |
508 | // Search the schema for the keyword
509 | const searchResults = await searchGraphQLSchema(
510 | schemaOverview,
511 | keyword,
512 | accountId,
513 | props.accessToken,
514 | maxDetailsToFetch,
515 | onlyObjectTypes
516 | )
517 |
518 | // Filter out internal types if requested
519 | if (!includeInternalTypes) {
520 | searchResults.types = searchResults.types.filter((t) => !t.name.startsWith('__'))
521 | searchResults.fields = searchResults.fields.filter((f) => !f.typeName.startsWith('__'))
522 | searchResults.enumValues = searchResults.enumValues.filter(
523 | (e) => !e.typeName.startsWith('__')
524 | )
525 | searchResults.args = searchResults.args.filter((a) => !a.typeName.startsWith('__'))
526 | }
527 |
528 | // Filter out items without descriptions when onlyObjectTypes is true
529 | if (onlyObjectTypes) {
530 | searchResults.types = searchResults.types.filter((t) => {
531 | return t.description && t.description.trim() !== ''
532 | })
533 | searchResults.fields = searchResults.fields.filter((f) => {
534 | return f.description && f.description.trim() !== ''
535 | })
536 | searchResults.enumValues = searchResults.enumValues.filter((e) => {
537 | return e.description && e.description.trim() !== ''
538 | })
539 | searchResults.args = searchResults.args.filter((a) => {
540 | return a.description && a.description.trim() !== ''
541 | })
542 | }
543 |
544 | // Add summary information
545 | const results = {
546 | keyword,
547 | summary: {
548 | totalMatches:
549 | searchResults.types.length +
550 | searchResults.fields.length +
551 | searchResults.enumValues.length +
552 | searchResults.args.length,
553 | typeMatches: searchResults.types.length,
554 | fieldMatches: searchResults.fields.length,
555 | enumValueMatches: searchResults.enumValues.length,
556 | argumentMatches: searchResults.args.length,
557 | },
558 | results: searchResults,
559 | }
560 |
561 | return {
562 | content: [
563 | {
564 | type: 'text',
565 | text: JSON.stringify(results),
566 | },
567 | ],
568 | }
569 | } catch (error) {
570 | return {
571 | content: [
572 | {
573 | type: 'text',
574 | text: JSON.stringify({
575 | error: `Error searching GraphQL schema: ${error instanceof Error ? error.message : String(error)}`,
576 | }),
577 | },
578 | ],
579 | }
580 | }
581 | }
582 | )
583 |
584 | // Tool to fetch the GraphQL schema overview (high-level structure)
585 | agent.server.tool(
586 | 'graphql_schema_overview',
587 | `Fetch the high-level overview of the Cloudflare GraphQL API schema
588 |
589 | Use this tool when:
590 |
591 | - A user requests insights into the structure or capabilities of Cloudflare’s GraphQL API.
592 | - You need to explore available types, queries, mutations, or schema relationships exposed by Cloudflare’s GraphQL interface.
593 | - You're generating or validating GraphQL queries against Cloudflare’s schema.
594 | - You are troubleshooting or developing integrations with Cloudflare’s API and require up-to-date schema information.
595 |
596 | This tool returns a high-level summary of the Cloudflare GraphQL API schema. It provides a structured outline of API entry points, data models, and relationships to help guide query construction or system integration.
597 | `,
598 | {
599 | pageSize: z
600 | .number()
601 | .min(10)
602 | .max(1000)
603 | .default(100)
604 | .describe('Number of types to return per page'),
605 | page: z.number().min(1).default(1).describe('Page number to fetch'),
606 | },
607 | async (params) => {
608 | const { pageSize = 100, page = 1 } = params
609 | const accountId = await agent.getActiveAccountId()
610 | if (!accountId) {
611 | return {
612 | content: [
613 | {
614 | type: 'text',
615 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
616 | },
617 | ],
618 | }
619 | }
620 |
621 | try {
622 | const props = getProps(agent)
623 | const schemaOverview = await fetchSchemaOverview(props.accessToken)
624 |
625 | // Apply pagination to the types array
626 | const allTypes = schemaOverview.data.__schema.types || []
627 | const totalTypes = allTypes.length
628 | const totalPages = Math.ceil(totalTypes / pageSize)
629 |
630 | // Calculate start and end indices for the current page
631 | const startIndex = (page - 1) * pageSize
632 | const endIndex = Math.min(startIndex + pageSize, totalTypes)
633 |
634 | // Create a paginated version of the schema
635 | const paginatedSchema = {
636 | data: {
637 | __schema: {
638 | queryType: schemaOverview.data.__schema.queryType,
639 | mutationType: schemaOverview.data.__schema.mutationType,
640 | subscriptionType: schemaOverview.data.__schema.subscriptionType,
641 | types: allTypes.slice(startIndex, endIndex),
642 | },
643 | },
644 | pagination: {
645 | page,
646 | pageSize,
647 | totalTypes,
648 | totalPages,
649 | hasNextPage: page < totalPages,
650 | hasPreviousPage: page > 1,
651 | },
652 | }
653 |
654 | return {
655 | content: [
656 | {
657 | type: 'text',
658 | text: JSON.stringify(paginatedSchema),
659 | },
660 | ],
661 | }
662 | } catch (error) {
663 | return {
664 | content: [
665 | {
666 | type: 'text',
667 | text: JSON.stringify({
668 | error: `Error fetching GraphQL schema overview: ${error instanceof Error ? error.message : String(error)}`,
669 | }),
670 | },
671 | ],
672 | }
673 | }
674 | }
675 | )
676 |
677 | // Tool to fetch detailed information about a specific GraphQL type
678 | agent.server.tool(
679 | 'graphql_type_details',
680 | `Fetch detailed information about a specific GraphQL type (dataset)
681 |
682 | IMPORTANT: After exploring the schema, DO NOT generate overly complicated GraphQL queries that the user didn't explicitly ask for. Only include fields that were specifically requested.
683 |
684 | Use this tool when:
685 |
686 | - You need to explore the fields by the type name (dataset) for detailed information
687 | - You're building or debugging GraphQL queries and want to ensure the correct usage of schema components
688 | - You need contextual information about how a certain concept or object is represented in Cloudflare's GraphQL API.
689 |
690 | Guidelines for query construction:
691 | - Keep queries as simple as possible while fulfilling the user's request
692 | - Only include fields that the user specifically asked for
693 | - Do not add dimensions or additional fields unless explicitly requested
694 | - When in doubt, ask the user for clarification rather than creating a complex query
695 | `,
696 | {
697 | typeName: z
698 | .string()
699 | .describe('The type name (dataset) of the GraphQL type to fetch details for'),
700 | fieldsPageSize: z
701 | .number()
702 | .min(5)
703 | .max(500)
704 | .default(50)
705 | .describe('Number of fields to return per page'),
706 | fieldsPage: z.number().min(1).default(1).describe('Page number for fields to fetch'),
707 | enumValuesPageSize: z
708 | .number()
709 | .min(5)
710 | .max(500)
711 | .default(50)
712 | .describe('Number of enum values to return per page'),
713 | enumValuesPage: z.number().min(1).default(1).describe('Page number for enum values to fetch'),
714 | },
715 | async (params) => {
716 | const {
717 | typeName,
718 | fieldsPageSize = 50,
719 | fieldsPage = 1,
720 | enumValuesPageSize = 50,
721 | enumValuesPage = 1,
722 | } = params
723 |
724 | const accountId = await agent.getActiveAccountId()
725 | if (!accountId) {
726 | return {
727 | content: [
728 | {
729 | type: 'text',
730 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
731 | },
732 | ],
733 | }
734 | }
735 |
736 | try {
737 | const props = getProps(agent)
738 | const typeDetails = await fetchTypeDetails(typeName, props.accessToken)
739 |
740 | // Apply pagination to fields if they exist
741 | const allFields = typeDetails.data.__type.fields || []
742 | const totalFields = allFields.length
743 | const totalFieldsPages = Math.ceil(totalFields / fieldsPageSize)
744 |
745 | // Calculate start and end indices for the fields page
746 | const fieldsStartIndex = (fieldsPage - 1) * fieldsPageSize
747 | const fieldsEndIndex = Math.min(fieldsStartIndex + fieldsPageSize, totalFields)
748 |
749 | // Apply pagination to enum values if they exist
750 | const allEnumValues = typeDetails.data.__type.enumValues || []
751 | const totalEnumValues = allEnumValues.length
752 | const totalEnumValuesPages = Math.ceil(totalEnumValues / enumValuesPageSize)
753 |
754 | // Calculate start and end indices for the enum values page
755 | const enumValuesStartIndex = (enumValuesPage - 1) * enumValuesPageSize
756 | const enumValuesEndIndex = Math.min(
757 | enumValuesStartIndex + enumValuesPageSize,
758 | totalEnumValues
759 | )
760 |
761 | // Create a paginated version of the type details
762 | const paginatedTypeDetails = {
763 | data: {
764 | __type: {
765 | ...typeDetails.data.__type,
766 | fields: allFields.slice(fieldsStartIndex, fieldsEndIndex),
767 | enumValues: allEnumValues.slice(enumValuesStartIndex, enumValuesEndIndex),
768 | },
769 | },
770 | pagination: {
771 | fields: {
772 | page: fieldsPage,
773 | pageSize: fieldsPageSize,
774 | totalFields,
775 | totalPages: totalFieldsPages,
776 | hasNextPage: fieldsPage < totalFieldsPages,
777 | hasPreviousPage: fieldsPage > 1,
778 | },
779 | enumValues: {
780 | page: enumValuesPage,
781 | pageSize: enumValuesPageSize,
782 | totalEnumValues,
783 | totalPages: totalEnumValuesPages,
784 | hasNextPage: enumValuesPage < totalEnumValuesPages,
785 | hasPreviousPage: enumValuesPage > 1,
786 | },
787 | },
788 | }
789 |
790 | return {
791 | content: [
792 | {
793 | type: 'text',
794 | text: JSON.stringify(paginatedTypeDetails),
795 | },
796 | ],
797 | }
798 | } catch (error) {
799 | return {
800 | content: [
801 | {
802 | type: 'text',
803 | text: JSON.stringify({
804 | error: `Error fetching type details: ${error instanceof Error ? error.message : String(error)}`,
805 | }),
806 | },
807 | ],
808 | }
809 | }
810 | }
811 | )
812 |
813 | // Tool to fetch the complete GraphQL schema (combines overview and important type details)
814 | agent.server.tool(
815 | 'graphql_complete_schema',
816 | 'Fetch the complete Cloudflare GraphQL API schema (combines overview and important type details)',
817 | {
818 | typesPageSize: z
819 | .number()
820 | .min(10)
821 | .max(500)
822 | .default(100)
823 | .describe('Number of types to return per page'),
824 | typesPage: z.number().min(1).default(1).describe('Page number for types to fetch'),
825 | includeRootTypeDetails: z
826 | .boolean()
827 | .default(true)
828 | .describe('Whether to include detailed information about root types'),
829 | maxTypeDetailsToFetch: z
830 | .number()
831 | .min(0)
832 | .max(10)
833 | .default(3)
834 | .describe('Maximum number of important types to fetch details for'),
835 | },
836 | async (params) => {
837 | const {
838 | typesPageSize = 100,
839 | typesPage = 1,
840 | includeRootTypeDetails = true,
841 | maxTypeDetailsToFetch = 3,
842 | } = params
843 |
844 | const accountId = await agent.getActiveAccountId()
845 | if (!accountId) {
846 | return {
847 | content: [
848 | {
849 | type: 'text',
850 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
851 | },
852 | ],
853 | }
854 | }
855 |
856 | try {
857 | const props = getProps(agent)
858 | // First fetch the schema overview
859 | const schemaOverview = await fetchSchemaOverview(props.accessToken)
860 |
861 | // Apply pagination to the types array
862 | const allTypes = schemaOverview.data.__schema.types || []
863 | const totalTypes = allTypes.length
864 | const totalPages = Math.ceil(totalTypes / typesPageSize)
865 |
866 | // Calculate start and end indices for the current page
867 | const startIndex = (typesPage - 1) * typesPageSize
868 | const endIndex = Math.min(startIndex + typesPageSize, totalTypes)
869 |
870 | // Get the paginated types
871 | const paginatedTypes = allTypes.slice(startIndex, endIndex)
872 |
873 | // Create the base schema with paginated types
874 | const schema: {
875 | data: {
876 | __schema: {
877 | queryType: { name: string } | null
878 | mutationType: { name: string } | null
879 | subscriptionType: { name: string } | null
880 | types: Array<{
881 | name: string
882 | kind: string
883 | description: string | null
884 | }>
885 | }
886 | }
887 | typeDetails: Record<string, GraphQLType>
888 | pagination: {
889 | types: {
890 | page: number
891 | pageSize: number
892 | totalTypes: number
893 | totalPages: number
894 | hasNextPage: boolean
895 | hasPreviousPage: boolean
896 | }
897 | }
898 | } = {
899 | data: {
900 | __schema: {
901 | queryType: schemaOverview.data.__schema.queryType,
902 | mutationType: schemaOverview.data.__schema.mutationType,
903 | subscriptionType: schemaOverview.data.__schema.subscriptionType,
904 | types: paginatedTypes,
905 | },
906 | },
907 | typeDetails: {} as Record<string, GraphQLType>,
908 | pagination: {
909 | types: {
910 | page: typesPage,
911 | pageSize: typesPageSize,
912 | totalTypes,
913 | totalPages,
914 | hasNextPage: typesPage < totalPages,
915 | hasPreviousPage: typesPage > 1,
916 | },
917 | },
918 | }
919 |
920 | // If requested, fetch details for root types
921 | if (includeRootTypeDetails) {
922 | // Identify important root types
923 | const rootTypes = [
924 | schemaOverview.data.__schema.queryType?.name,
925 | ...(schemaOverview.data.__schema.mutationType?.name
926 | ? [schemaOverview.data.__schema.mutationType.name]
927 | : []),
928 | ].filter(Boolean) as string[]
929 |
930 | // Limit the number of types to fetch details for
931 | const typesToFetch = rootTypes.slice(0, maxTypeDetailsToFetch)
932 |
933 | // Fetch details for each type
934 | for (const typeName of typesToFetch) {
935 | try {
936 | const typeDetails = await fetchTypeDetails(typeName, props.accessToken)
937 | if (typeDetails.data.__type) {
938 | schema.typeDetails[typeName] = typeDetails.data.__type
939 | }
940 | } catch (error) {
941 | console.error(`Error fetching details for type ${typeName}:`, error)
942 | }
943 | }
944 | }
945 |
946 | return {
947 | content: [
948 | {
949 | type: 'text',
950 | text: JSON.stringify(schema),
951 | },
952 | ],
953 | }
954 | } catch (error) {
955 | return {
956 | content: [
957 | {
958 | type: 'text',
959 | text: JSON.stringify({
960 | error: `Error fetching GraphQL schema: ${error instanceof Error ? error.message : String(error)}`,
961 | }),
962 | },
963 | ],
964 | }
965 | }
966 | }
967 | )
968 |
969 | // Tool to execute a GraphQL query
970 | agent.server.tool(
971 | 'graphql_query',
972 | `Execute a GraphQL query against the Cloudflare API
973 |
974 | IMPORTANT: ONLY execute the EXACT GraphQL query provided by the user. DO NOT generate complicated queries that the user didn't explicitly ask for.
975 |
976 | CRITICAL: When querying, make sure to set a LIMIT (e.g., first: 10, limit: 20) otherwise the response may be too large for the MCP server to process.
977 |
978 | Use this tool when:
979 |
980 | - A user provides a GraphQL query and expects real-time data from Cloudflare's API.
981 | - You need to retrieve live information from Cloudflare, such as analytics, logs, account data, or configuration details.
982 | - You want to validate the behavior of a GraphQL query or inspect its runtime results.
983 |
984 | This tool sends a user-defined GraphQL query to the Cloudflare API and returns the raw response exactly as received. When filtering or querying by time, use ISO 8601 datetime format (e.g., "2020-08-03T02:07:05Z").
985 |
986 | For each query execution, a clickable GraphQL API Explorer link will be provided in the response. Users can click this link to open the query in Cloudflare's GraphQL Explorer interface where they can further modify and experiment with the query.
987 |
988 | Guidelines:
989 | - Only use the exact query provided by the user. Do not modify or expand it unless explicitly requested.
990 | - Always suggest including limits in queries (e.g., first: 10, limit: 20) to prevent response size issues.
991 | - If a query fails due to size limits, advise the user to add or reduce limits in their query.
992 | `,
993 | {
994 | query: z.string().describe('The GraphQL query to execute'),
995 | variables: z.record(z.any()).optional().describe('Variables for the query'),
996 | },
997 | async (params) => {
998 | const accountId = await agent.getActiveAccountId()
999 | if (!accountId) {
1000 | return {
1001 | content: [
1002 | {
1003 | type: 'text',
1004 | text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
1005 | },
1006 | ],
1007 | }
1008 | }
1009 |
1010 | try {
1011 | const props = getProps(agent)
1012 | const { query, variables = {} } = params
1013 |
1014 | // Execute the GraphQL query and get the raw result
1015 | const result = await executeGraphQLQuery(query, variables, props.accessToken)
1016 |
1017 | // Generate GraphQL API Explorer link for this query
1018 | const compressedQuery = LZString.compressToEncodedURIComponent(query)
1019 | const compressedVariables = LZString.compressToEncodedURIComponent(
1020 | JSON.stringify(variables)
1021 | )
1022 | const explorerUrl = `https://graphql.cloudflare.com/explorer?query=${compressedQuery}&variables=${compressedVariables}`
1023 |
1024 | // Check if the response is too large (MCP server will fail if > 1MB)
1025 | const resultString = JSON.stringify(result)
1026 | const SIZE_LIMIT = 800000 // Set a safer limit (800KB) to ensure we stay under 1MB
1027 | if (resultString.length > SIZE_LIMIT) {
1028 | return {
1029 | content: [
1030 | {
1031 | type: 'text',
1032 | text: `ERROR: Query result exceeds size limit (${Math.round(resultString.length / 1024)}KB). MCP server will fail with results larger than 1MB. Please use a lower LIMIT in your GraphQL query to reduce the number of returned items. For example:
1033 |
1034 | - Add 'first: 10' or 'limit: 10' parameters to your query
1035 | - Reduce the number of requested fields
1036 | - Add more specific filters to narrow down results`,
1037 | },
1038 | ],
1039 | }
1040 | }
1041 |
1042 | return {
1043 | content: [
1044 | {
1045 | type: 'text',
1046 | text: `${resultString}\n\n**[Open in GraphQL Explorer](${explorerUrl})**\nClick the link above to view and modify this query in the Cloudflare GraphQL API Explorer.`,
1047 | },
1048 | ],
1049 | }
1050 | } catch (error) {
1051 | return {
1052 | content: [
1053 | {
1054 | type: 'text',
1055 | text: JSON.stringify({
1056 | error: `Error executing GraphQL query: ${error instanceof Error ? error.message : String(error)}`,
1057 | }),
1058 | },
1059 | ],
1060 | }
1061 | }
1062 | }
1063 | )
1064 |
1065 | // Tool to generate a GraphQL API Explorer link
1066 | agent.server.tool(
1067 | 'graphql_api_explorer',
1068 | `Generate a Cloudflare GraphQL API Explorer link
1069 |
1070 | Use this tool when:
1071 |
1072 | - A user asks for any GraphQL queries and wants to explore them in the Cloudflare GraphQL API Explorer.
1073 | - You want to provide a shareable link to a specific GraphQL query for the user to explore and modify.
1074 | - You need to help the user visualize or interact with GraphQL queries in a user-friendly interface.
1075 |
1076 | This tool generates a direct link to the Cloudflare GraphQL API Explorer with a pre-populated query and variables.
1077 | The response includes a clickable Markdown link that users can click to open the query in Cloudflare's interactive GraphQL playground.
1078 | The original query and variables are also displayed for reference.
1079 | `,
1080 | {
1081 | query: z.string().describe('The GraphQL query to include in the explorer link'),
1082 | variables: z.record(z.any()).optional().describe('Variables for the query in JSON format'),
1083 | },
1084 | async (params) => {
1085 | try {
1086 | const { query, variables = {} } = params
1087 |
1088 | // Compress the query and variables using lz-string
1089 | const compressedQuery = LZString.compressToEncodedURIComponent(query)
1090 | const compressedVariables = LZString.compressToEncodedURIComponent(
1091 | JSON.stringify(variables)
1092 | )
1093 |
1094 | // Generate the GraphQL API Explorer URL
1095 | const explorerUrl = `https://graphql.cloudflare.com/explorer?query=${compressedQuery}&variables=${compressedVariables}`
1096 |
1097 | return {
1098 | content: [
1099 | {
1100 | type: 'text',
1101 | text: `**[Open in GraphQL Explorer](${explorerUrl})**\n\nYou can click the link above to open the Cloudflare GraphQL API Explorer with your query pre-populated.\n\n**Query:**\n\`\`\`graphql\n${query}\n\`\`\`\n\n${Object.keys(variables).length > 0 ? `**Variables:**\n\`\`\`json\n${JSON.stringify(variables, null, 2)}\n\`\`\`\n` : ''}`,
1102 | },
1103 | ],
1104 | }
1105 | } catch (error) {
1106 | return {
1107 | content: [
1108 | {
1109 | type: 'text',
1110 | text: JSON.stringify({
1111 | error: `Error generating GraphQL API Explorer link: ${error instanceof Error ? error.message : String(error)}`,
1112 | }),
1113 | },
1114 | ],
1115 | }
1116 | }
1117 | }
1118 | )
1119 | }
1120 |
```