Skip to content

Commit 7d8d632

Browse files
LJAYil1shen
andauthored
feat(cloudflare): add official MCP provider (#232)
## Summary - add a locally executable `cloudflare_mcp` provider backed by Cloudflare’s official unified Streamable HTTP MCP endpoint - expose the current Code Mode tools: `docs`, `search`, and `execute` - support both OAuth 2.0 and Cloudflare API Token / Bearer Token credentials - validate credentials through MCP `tools/list` and require the expected official tools - route all MCP traffic through the provider SSRF-guarded fetch implementation ## Authentication API tokens are sent as `Authorization: Bearer <token>`. Both user and account API tokens are supported; account tokens should include **Account Resources: Read** so the official MCP server can auto-detect the account. OAuth uses Cloudflare’s published authorization metadata: - authorization: `https://mcp.cloudflare.com/authorize` - token / refresh: `https://mcp.cloudflare.com/token` - PKCE: S256 - required base scopes: `user:read account:read offline_access` The open-source runtime uses bring-your-own OAuth clients. For Cloudflare MCP, register a public client through `https://mcp.cloudflare.com/register` using the callback URL shown by Open Connector, then save the returned client ID in the OAuth Client configuration. No client secret is required (`token_endpoint_auth_method=none`). ## Implementation notes - the endpoint is fixed to `https://mcp.cloudflare.com/mcp` - `search` and `execute` preserve JSON results when possible and plain text otherwise - structured MCP results (including `docs`) are returned directly - MCP authorization, transport, protocol, and tool errors are mapped to stable provider errors - no third-party logo asset is copied into the repository ## Verification - `oxlint .` - `oxfmt --check .` - `node scripts/generate-catalog.ts` - `node scripts/typecheck.ts src scripts-all examples` - `vitest run` — 59 files, 562 tests passed --------- Co-authored-by: l1shen <648952316@qq.com>
1 parent 91c50b2 commit 7d8d632

5 files changed

Lines changed: 401 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { ActionDefinition } from "../../core/types.ts";
2+
3+
import { s } from "../../core/json-schema.ts";
4+
import { defineProviderAction } from "../../core/provider-definition.ts";
5+
6+
const service = "cloudflare_mcp";
7+
8+
const codeSchema = s.nonWhitespaceString(
9+
"A JavaScript async arrow function. Use `search` before `execute` to discover the exact Cloudflare API path and parameters.",
10+
);
11+
12+
export const cloudflareMcpActions: ActionDefinition[] = [
13+
defineProviderAction(service, {
14+
name: "docs",
15+
description: "Search the official Cloudflare developer documentation for relevant guidance and examples.",
16+
requiredScopes: [],
17+
providerPermissions: [],
18+
inputSchema: s.requiredObject("Input for searching Cloudflare documentation.", {
19+
query: s.nonWhitespaceString("The Cloudflare documentation search query."),
20+
}),
21+
outputSchema: s.looseObject("Semantically relevant Cloudflare documentation excerpts.", {
22+
results: s.array(
23+
"Matching documentation excerpts.",
24+
s.looseObject("One matching documentation excerpt.", {
25+
similarity: s.number("The semantic similarity score."),
26+
id: s.string("The source document ID."),
27+
url: s.url("The Cloudflare developer documentation URL."),
28+
title: s.string("The documentation page title."),
29+
text: s.string("The matching documentation text."),
30+
}),
31+
),
32+
}),
33+
}),
34+
defineProviderAction(service, {
35+
name: "search",
36+
description:
37+
"Run sandboxed JavaScript against Cloudflare's OpenAPI specification to discover API endpoints and parameters.",
38+
requiredScopes: [],
39+
providerPermissions: [],
40+
inputSchema: s.requiredObject("Input for searching the Cloudflare API specification.", {
41+
code: codeSchema,
42+
}),
43+
outputSchema: s.unknown("The value returned by the supplied search function."),
44+
followUpActions: ["cloudflare_mcp.execute"],
45+
}),
46+
defineProviderAction(service, {
47+
name: "execute",
48+
description:
49+
"Run sandboxed JavaScript on Cloudflare's official MCP server to call Cloudflare API endpoints discovered with `search`.",
50+
requiredScopes: [],
51+
providerPermissions: [],
52+
inputSchema: s.object(
53+
"Input for executing Cloudflare API calls.",
54+
{
55+
code: codeSchema,
56+
account_id: s.nonWhitespaceString(
57+
"An optional Cloudflare account ID. Supply it for a user token when the operation is account-scoped; account tokens are auto-detected.",
58+
),
59+
},
60+
{ required: ["code"] },
61+
),
62+
outputSchema: s.unknown("The value returned by the supplied Cloudflare API function."),
63+
}),
64+
];
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from "vitest";
2+
import { provider } from "./definition.ts";
3+
4+
describe("Cloudflare MCP provider definition", () => {
5+
it("supports Cloudflare MCP OAuth and API Bearer tokens", () => {
6+
const oauth = provider.auth.find((auth) => auth.type === "oauth2");
7+
const apiKey = provider.auth.find((auth) => auth.type === "api_key");
8+
9+
expect(oauth).toMatchObject({
10+
authorizationUrl: "https://mcp.cloudflare.com/authorize",
11+
tokenUrl: "https://mcp.cloudflare.com/token",
12+
refreshTokenUrl: "https://mcp.cloudflare.com/token",
13+
tokenEndpointAuthMethod: "none",
14+
pkce: { method: "S256" },
15+
});
16+
expect(oauth?.scopes).toEqual(["user:read", "account:read", "offline_access"]);
17+
expect(apiKey?.label).toContain("Bearer Token");
18+
});
19+
20+
it("exposes all tools from the official code-mode MCP server", () => {
21+
expect(provider.actions.map((action) => action.name)).toEqual(["docs", "search", "execute"]);
22+
});
23+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { ProviderDefinition } from "../../core/types.ts";
2+
3+
import { cloudflareMcpActions } from "./actions.ts";
4+
5+
const service = "cloudflare_mcp";
6+
7+
/**
8+
* Cloudflare provider backed by Cloudflare's official unified MCP service.
9+
*
10+
* API tokens work directly. OAuth clients use Cloudflare's dynamic client
11+
* registration endpoint and the local Open Connector OAuth callback URL.
12+
*/
13+
export const provider: ProviderDefinition = {
14+
service,
15+
displayName: "Cloudflare MCP",
16+
description:
17+
"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.",
18+
categories: ["Developer Tools", "Infrastructure"],
19+
authTypes: ["oauth2", "api_key"],
20+
auth: [
21+
{
22+
type: "oauth2",
23+
authorizationUrl: "https://mcp.cloudflare.com/authorize",
24+
tokenUrl: "https://mcp.cloudflare.com/token",
25+
refreshTokenUrl: "https://mcp.cloudflare.com/token",
26+
scopes: ["user:read", "account:read", "offline_access"],
27+
tokenEndpointAuthMethod: "none",
28+
pkce: { method: "S256" },
29+
},
30+
{
31+
type: "api_key",
32+
label: "Cloudflare API Token / Bearer Token",
33+
placeholder: "Paste a Cloudflare API token",
34+
description:
35+
"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.",
36+
},
37+
],
38+
homepageUrl: "https://github.com/cloudflare/mcp",
39+
actions: cloudflareMcpActions,
40+
};
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { cloudflareMcpActionHandlers, credentialValidators } from "./executors.ts";
3+
4+
describe("Cloudflare MCP executors", () => {
5+
it("sends bearer auth and maps search to the official MCP tool", async () => {
6+
const calls: Array<Record<string, unknown>> = [];
7+
const fetcher = createMcpFetch(calls, "cf-token");
8+
9+
const output = await cloudflareMcpActionHandlers.search(
10+
{ code: "async () => ['workers']" },
11+
{ accessToken: "cf-token", fetcher },
12+
);
13+
14+
expect(output).toEqual(["workers"]);
15+
expect(calls.find((call) => call.method === "tools/call")?.params).toEqual({
16+
name: "search",
17+
arguments: { code: "async () => ['workers']" },
18+
});
19+
expect(fetcher).toHaveBeenCalled();
20+
});
21+
22+
it("validates both API-token and OAuth bearer credentials with tools/list", async () => {
23+
const apiKeyResult = await credentialValidators.apiKey!(
24+
{ apiKey: "api-token", values: {} },
25+
{ fetcher: createMcpFetch([], "api-token") },
26+
);
27+
const oauthResult = await credentialValidators.oauth2!(
28+
{
29+
authType: "oauth2",
30+
accessToken: "oauth-token",
31+
tokenType: "Bearer",
32+
profile: { accountId: "pending", displayName: "Pending", grantedScopes: [] },
33+
metadata: {},
34+
},
35+
{ fetcher: createMcpFetch([], "oauth-token") },
36+
);
37+
38+
expect(apiKeyResult?.metadata?.mcpTools).toEqual(["docs", "execute", "search"]);
39+
expect(oauthResult?.metadata?.mcpTools).toEqual(["docs", "execute", "search"]);
40+
});
41+
42+
it("does not start MCP traffic after the execution is cancelled", async () => {
43+
const controller = new AbortController();
44+
controller.abort();
45+
const fetcher = createMcpFetch([], "cf-token");
46+
47+
await expect(
48+
cloudflareMcpActionHandlers.search(
49+
{ code: "async () => ['workers']" },
50+
{ accessToken: "cf-token", fetcher, signal: controller.signal },
51+
),
52+
).rejects.toThrow();
53+
expect(fetcher).not.toHaveBeenCalled();
54+
});
55+
});
56+
57+
function createMcpFetch(calls: Array<Record<string, unknown>>, expectedToken: string): typeof fetch {
58+
return vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
59+
const headers = new Headers(init?.headers);
60+
const rawBody = typeof init?.body === "string" ? init.body : undefined;
61+
const request = rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {};
62+
calls.push(request);
63+
64+
expect(headers.get("authorization")).toBe(`Bearer ${expectedToken}`);
65+
if (init?.method === "DELETE") return new Response(null, { status: 200 });
66+
if (!("id" in request)) return new Response(null, { status: 202 });
67+
68+
const method = request.method;
69+
const result =
70+
method === "initialize"
71+
? {
72+
protocolVersion: "2025-03-26",
73+
capabilities: {},
74+
serverInfo: { name: "cloudflare-api", version: "1.0.0" },
75+
}
76+
: method === "tools/list"
77+
? {
78+
tools: ["docs", "execute", "search"].map((name) => ({
79+
name,
80+
inputSchema: { type: "object" },
81+
})),
82+
}
83+
: { content: [{ type: "text", text: '["workers"]' }] };
84+
85+
return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), {
86+
headers: {
87+
"content-type": "application/json",
88+
"mcp-session-id": "test-session",
89+
},
90+
});
91+
}) as typeof fetch;
92+
}

0 commit comments

Comments
 (0)