Skip to content

Commit 27b111b

Browse files
authored
feat(cloudflare): add Cloudflare Docs provider (oomol-lab#170)
## Summary Adds the Cloudflare Docs MCP provider as a runnable custom-credential provider. - Connects to the public Cloudflare Docs MCP endpoint at `https://docs.mcp.cloudflare.com/mcp`. - Uses `custom_credential` auth with no credential fields so the provider is not auto-enabled — users must explicitly connect before use. - Execution requires a stored connection via `requireCustomCredential`. ## Actions - `search_cloudflare_documentation` — Search Cloudflare documentation for Workers, Pages, R2, Images, Stream, D1, Durable Objects, KV, Workflows, Hyperdrive, Queues, AI Gateway, Zero Trust, and more. - `get_pages_to_workers_migration_guide` — Get the guide and instructions for migrating Cloudflare Pages projects to Cloudflare Workers. ## Changes - Add provider definition, action schemas, executors, and Cloudflare Docs MCP runtime. - Use custom_credential auth with empty fields so the provider requires explicit connection (not auto-enabled). - Add `requireCustomCredential` gate in executor createContext. - Use SSRF-guarded provider fetch for MCP transport. ## Testing - `npm run fix-check` - `npm run typecheck` - `npm test` - Live validation against a running instance: confirmed provider is not auto-enabled, action is refused without connection (HTTP 403/authorization_failed), connect completes with zero credential fields, action executes successfully after connecting.
1 parent dbc50f5 commit 27b111b

5 files changed

Lines changed: 199 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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_docs" as const;
7+
8+
export type CloudflareDocsActionName = "search_cloudflare_documentation" | "get_pages_to_workers_migration_guide";
9+
10+
export const cloudflareDocsActions: ActionDefinition[] = [
11+
defineProviderAction(service, {
12+
name: "search_cloudflare_documentation",
13+
description:
14+
"Search Cloudflare documentation for Workers, Pages, R2, Images, Stream, D1, Durable Objects, KV, Workflows, Hyperdrive, Queues, AI Search, Workers AI, Vectorize, AI Gateway, Browser Run, Zero Trust, Access, Tunnel, Gateway, Browser Isolation, WARP, DDOS, Magic Transit, Magic WAN, CDN, Cache, DNS, Zaraz, Argo, Rulesets, Terraform, Account and Billing.",
15+
requiredScopes: [],
16+
inputSchema: s.object("Input payload for search_cloudflare_documentation", {
17+
query: s.string("Search query for Cloudflare documentation.", { minLength: 1 }),
18+
}),
19+
outputSchema: s.looseObject("Documentation search results from Cloudflare Docs MCP."),
20+
}),
21+
defineProviderAction(service, {
22+
name: "get_pages_to_workers_migration_guide",
23+
description: "Get the guide and instructions for migrating Cloudflare Pages projects to Cloudflare Workers.",
24+
requiredScopes: [],
25+
inputSchema: s.object("Input payload for get_pages_to_workers_migration_guide", {}),
26+
outputSchema: s.looseObject("Cloudflare Pages to Workers migration guide."),
27+
}),
28+
];
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { ProviderDefinition } from "../../core/types.ts";
2+
3+
import { cloudflareDocsActions } from "./actions.ts";
4+
5+
const service = "cloudflare_docs";
6+
7+
export const provider: ProviderDefinition = {
8+
service,
9+
displayName: "Cloudflare Docs",
10+
categories: ["Developer Tools", "AI"],
11+
authTypes: ["custom_credential"],
12+
auth: [{ type: "custom_credential", fields: [] }],
13+
homepageUrl: "https://developers.cloudflare.com",
14+
actions: cloudflareDocsActions,
15+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { ExecutionContext, ProviderExecutors } from "../../core/types.ts";
2+
import type { CloudflareDocsActionContext } from "./runtime.ts";
3+
4+
import { defineProviderExecutors, requireCustomCredential } from "../provider-runtime.ts";
5+
import { cloudflareDocsActionHandlers } from "./runtime.ts";
6+
7+
const service = "cloudflare_docs";
8+
9+
export const executors: ProviderExecutors = defineProviderExecutors<CloudflareDocsActionContext>({
10+
service,
11+
handlers: cloudflareDocsActionHandlers,
12+
async createContext(context: ExecutionContext, fetcher: typeof fetch): Promise<CloudflareDocsActionContext> {
13+
await requireCustomCredential(context, service);
14+
return {
15+
fetcher,
16+
signal: context.signal,
17+
};
18+
},
19+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { getPagesToWorkersMigrationGuide, searchCloudflareDocumentation } from "./runtime.ts";
3+
4+
const mockSdk = vi.hoisted(() => {
5+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6+
let callToolImpl: any = vi.fn();
7+
return {
8+
Client: function () {
9+
return {
10+
connect: vi.fn().mockResolvedValue(undefined),
11+
callTool: callToolImpl,
12+
close: vi.fn().mockResolvedValue(undefined),
13+
};
14+
},
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16+
setCallToolImpl: (fn: any) => {
17+
callToolImpl = fn;
18+
},
19+
};
20+
});
21+
22+
vi.mock("@modelcontextprotocol/sdk/client/index.js", () => mockSdk);
23+
24+
vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
25+
StreamableHTTPClientTransport: vi.fn(),
26+
}));
27+
28+
describe("Cloudflare Docs runtime", () => {
29+
describe("searchCloudflareDocumentation", () => {
30+
it("rejects empty or missing query parameter", async () => {
31+
await expect(searchCloudflareDocumentation({}, {})).rejects.toThrow("query parameter is required");
32+
await expect(searchCloudflareDocumentation({ query: " " }, {})).rejects.toThrow("query parameter is required");
33+
});
34+
35+
it("rejects when MCP tool returns isError", async () => {
36+
mockSdk.setCallToolImpl(
37+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
38+
vi.fn().mockResolvedValue({
39+
content: [{ type: "text", text: "Tool execution failed" }],
40+
isError: true,
41+
} as any),
42+
);
43+
44+
await expect(
45+
searchCloudflareDocumentation({ query: "test" }, { fetcher: vi.fn() as unknown as typeof fetch }),
46+
).rejects.toThrow("Tool execution failed");
47+
});
48+
});
49+
50+
describe("getPagesToWorkersMigrationGuide", () => {
51+
it("is defined as a function", () => {
52+
expect(typeof getPagesToWorkersMigrationGuide).toBe("function");
53+
});
54+
});
55+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { ProviderRuntimeHandler } from "../provider-runtime.ts";
2+
import type { CloudflareDocsActionName } from "./actions.ts";
3+
4+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6+
import { optionalString } from "../../core/cast.ts";
7+
import { ProviderRequestError } from "../provider-runtime.ts";
8+
9+
const cloudflareDocsMcpUrl = "https://docs.mcp.cloudflare.com/mcp";
10+
11+
export interface CloudflareDocsActionContext {
12+
fetcher?: typeof fetch;
13+
signal?: AbortSignal;
14+
}
15+
16+
export const cloudflareDocsActionHandlers: Record<
17+
CloudflareDocsActionName,
18+
ProviderRuntimeHandler<CloudflareDocsActionContext>
19+
> = {
20+
search_cloudflare_documentation(input, context) {
21+
return searchCloudflareDocumentation(input, context);
22+
},
23+
get_pages_to_workers_migration_guide(input, context) {
24+
return getPagesToWorkersMigrationGuide(input, context);
25+
},
26+
};
27+
28+
export async function searchCloudflareDocumentation(
29+
input: Record<string, unknown>,
30+
context: CloudflareDocsActionContext,
31+
): Promise<Record<string, unknown>> {
32+
const query = optionalString(input.query)?.trim();
33+
if (!query) {
34+
throw new ProviderRequestError(400, "query parameter is required");
35+
}
36+
37+
return callCloudflareDocsTool("search_cloudflare_documentation", { query }, context);
38+
}
39+
40+
export async function getPagesToWorkersMigrationGuide(
41+
input: Record<string, unknown>,
42+
context: CloudflareDocsActionContext,
43+
): Promise<Record<string, unknown>> {
44+
return callCloudflareDocsTool("migrate_pages_to_workers_guide", input, context);
45+
}
46+
47+
async function callCloudflareDocsTool(
48+
toolName: string,
49+
args: Record<string, unknown>,
50+
context: CloudflareDocsActionContext,
51+
): Promise<Record<string, unknown>> {
52+
try {
53+
const transportOptions: Record<string, unknown> = {};
54+
if (context.fetcher) {
55+
transportOptions.fetch = context.fetcher;
56+
}
57+
const transport = new StreamableHTTPClientTransport(new URL(cloudflareDocsMcpUrl), transportOptions);
58+
const client = new Client({ name: "open-connector", version: "1.0.0" }, { capabilities: {} });
59+
await client.connect(transport);
60+
61+
try {
62+
const result = await client.callTool({
63+
name: toolName,
64+
arguments: args,
65+
});
66+
if (result.isError) {
67+
const content = result.content as Array<{ type?: string; text?: string }> | undefined;
68+
const text = content?.find((c) => c.type === "text")?.text;
69+
throw new ProviderRequestError(502, text ?? "Cloudflare Docs MCP tool returned an unknown error.");
70+
}
71+
return result as Record<string, unknown>;
72+
} finally {
73+
await client.close().catch(() => {});
74+
}
75+
} catch (error) {
76+
if (error instanceof ProviderRequestError) throw error;
77+
throw new ProviderRequestError(
78+
502,
79+
`Cloudflare Docs MCP request failed: ${error instanceof Error ? error.message : String(error)}`,
80+
);
81+
}
82+
}

0 commit comments

Comments
 (0)