-
Notifications
You must be signed in to change notification settings - Fork 344
feat(cloudflare): add official MCP provider #232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
l1shen
merged 3 commits into
oomol-lab:main
from
LJAYi:agent/add-cloudflare-mcp-provider
Jul 30, 2026
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import type { ActionDefinition } from "../../core/types.ts"; | ||
|
|
||
| import { s } from "../../core/json-schema.ts"; | ||
| import { defineProviderAction } from "../../core/provider-definition.ts"; | ||
|
|
||
| const service = "cloudflare_mcp"; | ||
|
|
||
| const codeSchema = s.nonWhitespaceString( | ||
| "A JavaScript async arrow function. Use `search` before `execute` to discover the exact Cloudflare API path and parameters.", | ||
| ); | ||
|
|
||
| export const cloudflareMcpActions: ActionDefinition[] = [ | ||
| defineProviderAction(service, { | ||
| name: "docs", | ||
| description: "Search the official Cloudflare developer documentation for relevant guidance and examples.", | ||
| requiredScopes: [], | ||
| providerPermissions: [], | ||
| inputSchema: s.requiredObject("Input for searching Cloudflare documentation.", { | ||
| query: s.nonWhitespaceString("The Cloudflare documentation search query."), | ||
| }), | ||
| outputSchema: s.looseObject("Semantically relevant Cloudflare documentation excerpts.", { | ||
| results: s.array( | ||
| "Matching documentation excerpts.", | ||
| s.looseObject("One matching documentation excerpt.", { | ||
| similarity: s.number("The semantic similarity score."), | ||
| id: s.string("The source document ID."), | ||
| url: s.url("The Cloudflare developer documentation URL."), | ||
| title: s.string("The documentation page title."), | ||
| text: s.string("The matching documentation text."), | ||
| }), | ||
| ), | ||
| }), | ||
| }), | ||
| defineProviderAction(service, { | ||
| name: "search", | ||
| description: | ||
| "Run sandboxed JavaScript against Cloudflare's OpenAPI specification to discover API endpoints and parameters.", | ||
| requiredScopes: [], | ||
| providerPermissions: [], | ||
| inputSchema: s.requiredObject("Input for searching the Cloudflare API specification.", { | ||
| code: codeSchema, | ||
| }), | ||
| outputSchema: s.unknown("The value returned by the supplied search function."), | ||
| followUpActions: ["cloudflare_mcp.execute"], | ||
| }), | ||
| defineProviderAction(service, { | ||
| name: "execute", | ||
| description: | ||
| "Run sandboxed JavaScript on Cloudflare's official MCP server to call Cloudflare API endpoints discovered with `search`.", | ||
| requiredScopes: [], | ||
| providerPermissions: [], | ||
| inputSchema: s.object( | ||
| "Input for executing Cloudflare API calls.", | ||
| { | ||
| code: codeSchema, | ||
| account_id: s.nonWhitespaceString( | ||
| "An optional Cloudflare account ID. Supply it for a user token when the operation is account-scoped; account tokens are auto-detected.", | ||
| ), | ||
| }, | ||
| { required: ["code"] }, | ||
| ), | ||
| outputSchema: s.unknown("The value returned by the supplied Cloudflare API function."), | ||
| }), | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { provider } from "./definition.ts"; | ||
|
|
||
| describe("Cloudflare MCP provider definition", () => { | ||
| it("supports Cloudflare MCP OAuth and API Bearer tokens", () => { | ||
| const oauth = provider.auth.find((auth) => auth.type === "oauth2"); | ||
| const apiKey = provider.auth.find((auth) => auth.type === "api_key"); | ||
|
|
||
| expect(oauth).toMatchObject({ | ||
| authorizationUrl: "https://mcp.cloudflare.com/authorize", | ||
| tokenUrl: "https://mcp.cloudflare.com/token", | ||
| refreshTokenUrl: "https://mcp.cloudflare.com/token", | ||
| tokenEndpointAuthMethod: "none", | ||
| pkce: { method: "S256" }, | ||
| }); | ||
| expect(oauth?.scopes).toEqual(["user:read", "account:read", "offline_access"]); | ||
| expect(apiKey?.label).toContain("Bearer Token"); | ||
| }); | ||
|
|
||
| it("exposes all tools from the official code-mode MCP server", () => { | ||
| expect(provider.actions.map((action) => action.name)).toEqual(["docs", "search", "execute"]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import type { ProviderDefinition } from "../../core/types.ts"; | ||
|
|
||
| import { cloudflareMcpActions } from "./actions.ts"; | ||
|
|
||
| const service = "cloudflare_mcp"; | ||
|
|
||
| /** | ||
| * Cloudflare provider backed by Cloudflare's official unified MCP service. | ||
| * | ||
| * API tokens work directly. OAuth clients use Cloudflare's dynamic client | ||
| * registration endpoint and the local Open Connector OAuth callback URL. | ||
| */ | ||
| export const provider: ProviderDefinition = { | ||
| service, | ||
| displayName: "Cloudflare MCP", | ||
| description: | ||
| "Search Cloudflare documentation and API schemas, then manage Cloudflare resources through the official unified MCP service. API tokens connect directly; OAuth uses a public client registered through https://mcp.cloudflare.com/register with this Open Connector deployment's callback URL.", | ||
| categories: ["Developer Tools", "Infrastructure"], | ||
| authTypes: ["oauth2", "api_key"], | ||
| auth: [ | ||
| { | ||
| type: "oauth2", | ||
| authorizationUrl: "https://mcp.cloudflare.com/authorize", | ||
| tokenUrl: "https://mcp.cloudflare.com/token", | ||
| refreshTokenUrl: "https://mcp.cloudflare.com/token", | ||
| scopes: ["user:read", "account:read", "offline_access"], | ||
| tokenEndpointAuthMethod: "none", | ||
| pkce: { method: "S256" }, | ||
| }, | ||
| { | ||
| type: "api_key", | ||
| label: "Cloudflare API Token / Bearer Token", | ||
| placeholder: "Paste a Cloudflare API token", | ||
| description: | ||
| "A Cloudflare user or account API token sent to the official MCP server as an Authorization Bearer token. Grant only the permissions needed by your workflows. Account tokens should also include Account Resources: Read so the MCP server can auto-detect the account ID.", | ||
| }, | ||
| ], | ||
| homepageUrl: "https://github.com/cloudflare/mcp", | ||
| actions: cloudflareMcpActions, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { cloudflareMcpActionHandlers, credentialValidators } from "./executors.ts"; | ||
|
|
||
| describe("Cloudflare MCP executors", () => { | ||
| it("sends bearer auth and maps search to the official MCP tool", async () => { | ||
| const calls: Array<Record<string, unknown>> = []; | ||
| const fetcher = createMcpFetch(calls); | ||
|
|
||
| const output = await cloudflareMcpActionHandlers.search( | ||
| { code: "async () => ['workers']" }, | ||
| { accessToken: "cf-token", fetcher }, | ||
| ); | ||
|
|
||
| expect(output).toEqual(["workers"]); | ||
| expect(calls.find((call) => call.method === "tools/call")?.params).toEqual({ | ||
| name: "search", | ||
| arguments: { code: "async () => ['workers']" }, | ||
| }); | ||
| expect(fetcher).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("validates both API-token and OAuth bearer credentials with tools/list", async () => { | ||
| const apiKeyResult = await credentialValidators.apiKey!( | ||
| { apiKey: "api-token", values: {} }, | ||
| { fetcher: createMcpFetch([]) }, | ||
| ); | ||
| const oauthResult = await credentialValidators.oauth2!( | ||
| { | ||
| authType: "oauth2", | ||
| accessToken: "oauth-token", | ||
| tokenType: "Bearer", | ||
| profile: { accountId: "pending", displayName: "Pending", grantedScopes: [] }, | ||
| metadata: {}, | ||
| }, | ||
| { fetcher: createMcpFetch([]) }, | ||
| ); | ||
|
|
||
| expect(apiKeyResult?.metadata?.mcpTools).toEqual(["docs", "execute", "search"]); | ||
| expect(oauthResult?.metadata?.mcpTools).toEqual(["docs", "execute", "search"]); | ||
| }); | ||
| }); | ||
|
|
||
| function createMcpFetch(calls: Array<Record<string, unknown>>): typeof fetch { | ||
| return vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { | ||
| const headers = new Headers(init?.headers); | ||
| const rawBody = typeof init?.body === "string" ? init.body : undefined; | ||
| const request = rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {}; | ||
| calls.push(request); | ||
|
|
||
| expect(headers.get("authorization")).toMatch(/^Bearer /); | ||
| if (init?.method === "DELETE") return new Response(null, { status: 200 }); | ||
| if (!("id" in request)) return new Response(null, { status: 202 }); | ||
|
|
||
| const method = request.method; | ||
| const result = | ||
| method === "initialize" | ||
| ? { | ||
| protocolVersion: "2025-03-26", | ||
| capabilities: {}, | ||
| serverInfo: { name: "cloudflare-api", version: "1.0.0" }, | ||
| } | ||
| : method === "tools/list" | ||
| ? { | ||
| tools: ["docs", "execute", "search"].map((name) => ({ | ||
| name, | ||
| inputSchema: { type: "object" }, | ||
| })), | ||
| } | ||
| : { content: [{ type: "text", text: '["workers"]' }] }; | ||
|
|
||
| return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), { | ||
| headers: { | ||
| "content-type": "application/json", | ||
| "mcp-session-id": "test-session", | ||
| }, | ||
| }); | ||
| }) as typeof fetch; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| import type { CredentialValidators, ProviderExecutors } from "../../core/types.ts"; | ||
| import type { BearerProviderContext, ProviderRuntimeHandler } from "../provider-runtime.ts"; | ||
|
|
||
| import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; | ||
| import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| import { McpError } from "@modelcontextprotocol/sdk/types.js"; | ||
| import { createHash } from "node:crypto"; | ||
| import { defineBearerProviderExecutors, providerUserAgent, ProviderRequestError } from "../provider-runtime.ts"; | ||
|
|
||
| const service = "cloudflare_mcp"; | ||
| const cloudflareMcpEndpoint = "https://mcp.cloudflare.com/mcp"; | ||
| const cloudflareMcpRequestTimeoutMs = 60_000; | ||
| const expectedTools = ["docs", "execute", "search"]; | ||
|
|
||
| type CloudflareMcpToolResult = Awaited<ReturnType<Client["callTool"]>>; | ||
|
|
||
| export const cloudflareMcpActionHandlers: Record<string, ProviderRuntimeHandler<BearerProviderContext>> = { | ||
| docs(input: Record<string, unknown>, context: BearerProviderContext) { | ||
| return callCloudflareMcpTool(context, "docs", input); | ||
| }, | ||
| search(input: Record<string, unknown>, context: BearerProviderContext) { | ||
| return callCloudflareMcpTool(context, "search", input); | ||
| }, | ||
| execute(input: Record<string, unknown>, context: BearerProviderContext) { | ||
| return callCloudflareMcpTool(context, "execute", input); | ||
| }, | ||
| }; | ||
|
|
||
| export const executors: ProviderExecutors = defineBearerProviderExecutors(service, cloudflareMcpActionHandlers, { | ||
| skipDnsValidation: true, | ||
| }); | ||
|
|
||
| export const credentialValidators: CredentialValidators = { | ||
| async apiKey(input, { fetcher, signal }) { | ||
| return validateCloudflareMcpCredential(input.apiKey, fetcher, signal); | ||
| }, | ||
| async oauth2(input, { fetcher, signal }) { | ||
| return validateCloudflareMcpCredential(input.accessToken, fetcher, signal); | ||
| }, | ||
| }; | ||
|
|
||
| async function validateCloudflareMcpCredential(accessToken: string, fetcher: typeof fetch, signal?: AbortSignal) { | ||
| const tools = await listCloudflareMcpTools({ accessToken, fetcher, signal }); | ||
| const toolNames = tools.map((tool) => tool.name).sort(); | ||
| const missingTools = expectedTools.filter((tool) => !toolNames.includes(tool)); | ||
| if (missingTools.length > 0) { | ||
| throw new ProviderRequestError( | ||
| 502, | ||
| `Cloudflare MCP did not advertise the expected tools: ${missingTools.join(", ")}`, | ||
| ); | ||
| } | ||
|
|
||
| const tokenHash = createHash("sha256").update(accessToken).digest("hex").slice(0, 16); | ||
| return { | ||
| profile: { | ||
| accountId: `cloudflare:mcp:${tokenHash}`, | ||
| displayName: `Cloudflare MCP · ${tokenHash.slice(-6)}`, | ||
| }, | ||
| grantedScopes: [], | ||
| metadata: { | ||
| mcpEndpoint: cloudflareMcpEndpoint, | ||
| mcpTools: toolNames, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| async function listCloudflareMcpTools(input: { accessToken: string; fetcher: typeof fetch; signal?: AbortSignal }) { | ||
| return withCloudflareMcpClient(input, async (client) => { | ||
| const result = await client.listTools({}, { timeout: cloudflareMcpRequestTimeoutMs }); | ||
| return result.tools; | ||
| }); | ||
| } | ||
|
|
||
| async function callCloudflareMcpTool( | ||
| context: BearerProviderContext, | ||
| toolName: string, | ||
| argumentsInput: Record<string, unknown>, | ||
| ): Promise<unknown> { | ||
| return withCloudflareMcpClient(context, async (client) => { | ||
| const result = await client.callTool({ name: toolName, arguments: argumentsInput }, undefined, { | ||
| timeout: cloudflareMcpRequestTimeoutMs, | ||
| }); | ||
| return normalizeCloudflareMcpToolResult(toolName, result); | ||
| }); | ||
| } | ||
|
|
||
| async function withCloudflareMcpClient<T>( | ||
| input: { accessToken: string; fetcher: typeof fetch; signal?: AbortSignal }, | ||
| run: (client: Client) => Promise<T>, | ||
| ): Promise<T> { | ||
| const headers = new Headers(); | ||
| headers.set("authorization", `Bearer ${input.accessToken}`); | ||
| headers.set("user-agent", providerUserAgent); | ||
| const transport = new StreamableHTTPClientTransport(new URL(cloudflareMcpEndpoint), { | ||
| fetch: input.fetcher, | ||
| requestInit: { headers, signal: input.signal }, | ||
| }); | ||
| const client = new Client({ name: "oomol-connect-cloudflare-mcp", version: "1.0.0" }); | ||
|
|
||
| try { | ||
| await client.connect(transport, { timeout: cloudflareMcpRequestTimeoutMs }); | ||
| return await run(client); | ||
| } catch (error) { | ||
| throw mapCloudflareMcpError(error); | ||
| } finally { | ||
| await client.close().catch(() => undefined); | ||
| } | ||
| } | ||
|
|
||
| function normalizeCloudflareMcpToolResult(toolName: string, result: CloudflareMcpToolResult): unknown { | ||
| if ("toolResult" in result) return result; | ||
| if (result.isError) { | ||
| throw new ProviderRequestError( | ||
| 502, | ||
| `Cloudflare MCP tool ${toolName} returned an error: ${formatCloudflareMcpToolContent(result)}`, | ||
| result, | ||
| ); | ||
| } | ||
| if (result.structuredContent) return result.structuredContent; | ||
|
|
||
| const textItems = result.content.filter((content) => content.type === "text"); | ||
| if (textItems.length === 1) { | ||
| try { | ||
| return JSON.parse(textItems[0]!.text) as unknown; | ||
| } catch { | ||
| return textItems[0]!.text; | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| function formatCloudflareMcpToolContent(result: Extract<CloudflareMcpToolResult, { content: unknown }>): string { | ||
| const text = result.content | ||
| .map((content) => { | ||
| if (content.type === "text") return content.text; | ||
| if (content.type === "resource") return "text" in content.resource ? content.resource.text : content.resource.uri; | ||
| if (content.type === "resource_link") return content.uri; | ||
| return content.type; | ||
| }) | ||
| .filter(Boolean) | ||
| .join("; "); | ||
| return text.slice(0, 300) || "empty error content"; | ||
| } | ||
|
|
||
| function mapCloudflareMcpError(error: unknown): ProviderRequestError { | ||
| if (error instanceof ProviderRequestError) return error; | ||
| if (error instanceof UnauthorizedError) { | ||
| return new ProviderRequestError(401, "Cloudflare MCP credential is invalid or expired", error); | ||
| } | ||
| if (error instanceof StreamableHTTPError) { | ||
| const status = error.code; | ||
| return new ProviderRequestError( | ||
| status === 401 || status === 403 | ||
| ? 401 | ||
| : status === 429 | ||
| ? 429 | ||
| : status && status >= 400 && status < 500 | ||
| ? 400 | ||
| : 502, | ||
| `Cloudflare MCP request failed: ${error.message}`, | ||
| error, | ||
| ); | ||
| } | ||
| if (error instanceof McpError) { | ||
| return new ProviderRequestError(502, `Cloudflare MCP request failed: ${error.message}`, error); | ||
| } | ||
| return new ProviderRequestError( | ||
| 502, | ||
| error instanceof Error ? `Cloudflare MCP request failed: ${error.message}` : "Cloudflare MCP request failed", | ||
| error, | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.