Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,
};
78 changes: 78 additions & 0 deletions src/providers/cloudflare_mcp/executors.test.ts
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 /);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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;
}
173 changes: 173 additions & 0 deletions src/providers/cloudflare_mcp/executors.ts
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,
);
}
Loading