Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/providers/cloudflare_mcp/actions.ts
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."),
}),
];
23 changes: 23 additions & 0 deletions src/providers/cloudflare_mcp/definition.test.ts
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"]);
});
});
40 changes: 40 additions & 0 deletions src/providers/cloudflare_mcp/definition.ts
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,
};
92 changes: 92 additions & 0 deletions src/providers/cloudflare_mcp/executors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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, "cf-token");

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([], "api-token") },
);
const oauthResult = await credentialValidators.oauth2!(
{
authType: "oauth2",
accessToken: "oauth-token",
tokenType: "Bearer",
profile: { accountId: "pending", displayName: "Pending", grantedScopes: [] },
metadata: {},
},
{ fetcher: createMcpFetch([], "oauth-token") },
);

expect(apiKeyResult?.metadata?.mcpTools).toEqual(["docs", "execute", "search"]);
expect(oauthResult?.metadata?.mcpTools).toEqual(["docs", "execute", "search"]);
});

it("does not start MCP traffic after the execution is cancelled", async () => {
const controller = new AbortController();
controller.abort();
const fetcher = createMcpFetch([], "cf-token");

await expect(
cloudflareMcpActionHandlers.search(
{ code: "async () => ['workers']" },
{ accessToken: "cf-token", fetcher, signal: controller.signal },
),
).rejects.toThrow();
expect(fetcher).not.toHaveBeenCalled();
});
});

function createMcpFetch(calls: Array<Record<string, unknown>>, expectedToken: string): 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")).toBe(`Bearer ${expectedToken}`);
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;
}
Loading
Loading