-
Notifications
You must be signed in to change notification settings - Fork 436
feat: add Web Search MCP server (stateless, /mcp, OAuth + API token) #383
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
Open
G4brym
wants to merge
1
commit into
cloudflare:main
Choose a base branch
from
G4brym:add-websearch-mcp-server
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
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,5 @@ | ||
| --- | ||
| "websearch": minor | ||
| --- | ||
|
|
||
| Add the Web Search MCP server (`websearch.mcp.cloudflare.com`). It is a stateless (`createMcpHandler`) server exposing only `/mcp`, with a `web_search` tool that takes an `account_id` parameter and is gated behind the `websearch.run` OAuth scope. Supports both OAuth and raw Cloudflare API token (Bearer) authentication. |
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,5 @@ | ||
| CLOUDFLARE_CLIENT_ID= | ||
| CLOUDFLARE_CLIENT_SECRET= | ||
| MCP_COOKIE_ENCRYPTION_KEY= | ||
| DEV_DISABLE_OAUTH= | ||
| DEV_CLOUDFLARE_API_TOKEN= |
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,5 @@ | ||
| /** @type {import("eslint").Linter.Config} */ | ||
| module.exports = { | ||
| root: true, | ||
| extends: ['@repo/eslint-config/default.cjs'], | ||
| } |
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,59 @@ | ||
| # Web Search MCP Server 🔎 | ||
|
|
||
| This is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that supports remote MCP | ||
| connections, with Cloudflare OAuth built-in. | ||
|
|
||
| It integrates a web search **discovery** tool powered by the Cloudflare Web Search API: it returns ranked links with metadata (title, description, image, and more), not page contents, for agents to fetch themselves. | ||
|
|
||
| ## 🔨 Available Tools | ||
|
|
||
| Currently available tools: | ||
|
|
||
| | **Category** | **Tool** | **Description** | | ||
| | -------------- | ------------ | ------------------------------------------------------------------------------------------------------------- | | ||
| | **Web Search** | `web_search` | Discovery tool — returns ranked links with metadata (title, description, image, and more), not page contents. | | ||
|
|
||
| This MCP server is still a work in progress, and we plan to add more tools in the future. | ||
|
|
||
| ### Prompt Examples | ||
|
|
||
| - `Search the web for the latest Cloudflare Workers release notes` | ||
| - `Find recent articles about MCP servers` | ||
|
|
||
| ## Access the remote MCP server from any MCP Client | ||
|
|
||
| This server exposes a native remote MCP endpoint at `https://websearch.mcp.cloudflare.com/mcp`. Any MCP client with first-class support for remote servers can connect to it directly, with no local proxy required. For example, [Cloudflare AI Playground](https://playground.ai.cloudflare.com/) lets you paste the server URL directly. | ||
|
|
||
| For clients that use a JSON config file, add it as a remote server: | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "cloudflare-websearch": { | ||
| "url": "https://websearch.mcp.cloudflare.com/mcp" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Connecting to the URL triggers the Cloudflare OAuth flow in your browser. To skip OAuth, send a Cloudflare API token as a `Bearer` token instead: | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "cloudflare-websearch": { | ||
| "url": "https://websearch.mcp.cloudflare.com/mcp", | ||
| "headers": { | ||
| "Authorization": "Bearer <your-cloudflare-api-token>" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Authentication | ||
|
|
||
| This server supports two authentication modes: | ||
|
|
||
| - **OAuth** — connect to the server URL and complete the Cloudflare OAuth flow. | ||
| - **API token** — send a Cloudflare API token as a `Bearer` token in the `Authorization` header to skip OAuth and call the Web Search API directly. |
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,34 @@ | ||
| { | ||
| "name": "websearch", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "scripts": { | ||
| "check:lint": "run-eslint-workers", | ||
| "check:types": "run-tsc", | ||
| "deploy": "run-wrangler-deploy", | ||
| "dev": "wrangler dev", | ||
| "start": "wrangler dev", | ||
| "types": "wrangler types", | ||
| "test": "vitest run" | ||
| }, | ||
| "dependencies": { | ||
| "@cloudflare/workers-oauth-provider": "0.4.0", | ||
| "@hono/zod-validator": "0.4.3", | ||
| "@modelcontextprotocol/sdk": "1.29.0", | ||
| "@repo/mcp-common": "workspace:*", | ||
| "@repo/mcp-observability": "workspace:*", | ||
| "agents": "0.13.3", | ||
| "cloudflare": "4.2.0", | ||
| "hono": "4.7.6", | ||
| "zod": "4.4.3" | ||
| }, | ||
| "devDependencies": { | ||
| "@cloudflare/vitest-pool-workers": "0.16.11", | ||
| "@types/node": "22.15.17", | ||
| "prettier": "3.5.3", | ||
| "typescript": "5.5.4", | ||
| "vitest": "4.1.8", | ||
| "wrangler": "4.96.0" | ||
| }, | ||
| "type": "module" | ||
| } |
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,72 @@ | ||
| import { z } from 'zod' | ||
|
|
||
| import { buildAccountTool } from '@repo/mcp-common/src/account-tool' | ||
|
|
||
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' | ||
| import type { AccountManager } from '@repo/mcp-common/src/account-manager' | ||
|
|
||
| const WEBSEARCH_API_BASE = 'https://api.cloudflare.com/client/v4/accounts' | ||
|
|
||
| const QueryParam = z.string().min(1).describe('The web search query to run.') | ||
| // Not range-validated here on purpose — the Web Search API enforces the cap (20). | ||
| const MaxResultsParam = z | ||
| .number() | ||
| .int() | ||
| .optional() | ||
| .describe('Maximum number of results to return (up to 20).') | ||
|
|
||
| // `account_id` is appended by buildAccountTool only when the credentials span multiple accounts; | ||
| // otherwise the account is resolved from auth (cf-account-id header → account_id arg fallback). | ||
| export function registerWebSearchTools( | ||
| server: McpServer, | ||
| accountManager: AccountManager, | ||
| accessToken: string | ||
| ) { | ||
| const { shape, callback } = buildAccountTool( | ||
| accountManager, | ||
| { query: QueryParam, max_results: MaxResultsParam }, | ||
| async ({ query, max_results }, accountId) => { | ||
| try { | ||
| const res = await fetch(`${WEBSEARCH_API_BASE}/${accountId}/websearch/search`, { | ||
| method: 'POST', | ||
| headers: { | ||
| Authorization: `Bearer ${accessToken}`, | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify({ query, max_results }), | ||
| }) | ||
|
|
||
| if (!res.ok) { | ||
| const errorData = await res.json().catch(() => ({})) | ||
| throw new Error(`Web search failed: ${res.status} ${JSON.stringify(errorData)}`) | ||
| } | ||
|
|
||
| const data = await res.json() | ||
| return { | ||
| content: [{ type: 'text' as const, text: JSON.stringify(data) }], | ||
| } | ||
| } catch (error) { | ||
| return { | ||
| isError: true, | ||
| content: [ | ||
| { | ||
| type: 'text' as const, | ||
| text: `Error running web search: ${error instanceof Error ? error.message : String(error)}`, | ||
| }, | ||
| ], | ||
| } | ||
| } | ||
| } | ||
| ) | ||
|
|
||
| server.registerTool( | ||
| 'web_search', | ||
| { | ||
| description: | ||
| 'Discover web pages via the Cloudflare Web Search API. Returns a ranked list of links with metadata (title, description, image, and more), not page contents — fetch the links yourself to read them.', | ||
| inputSchema: shape, | ||
| annotations: { readOnlyHint: true }, | ||
| }, | ||
| callback | ||
| ) | ||
| } |
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,110 @@ | ||
| import OAuthProvider from '@cloudflare/workers-oauth-provider' | ||
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' | ||
| import { createMcpHandler } from 'agents/mcp' | ||
|
|
||
| import { AccountManager } from '@repo/mcp-common/src/account-manager' | ||
| import { isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode' | ||
| import { | ||
| createAuthHandlers, | ||
| getUserAndAccounts, | ||
| handleTokenExchangeCallback, | ||
| } from '@repo/mcp-common/src/cloudflare-oauth-handler' | ||
| import { getEnv } from '@repo/mcp-common/src/env' | ||
| import { RequiredScopes } from '@repo/mcp-common/src/scopes' | ||
| import { MetricsTracker } from '@repo/mcp-observability' | ||
|
|
||
| import { registerWebSearchTools } from './tools/websearch.tools' | ||
| import { BASE_INSTRUCTIONS } from './websearch.context' | ||
|
|
||
| import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler' | ||
| import type { Env } from './websearch.context' | ||
|
|
||
| const env = getEnv<Env>() | ||
|
|
||
| const metrics = new MetricsTracker(env.MCP_METRICS, { | ||
| name: env.MCP_SERVER_NAME, | ||
| version: env.MCP_SERVER_VERSION, | ||
| }) | ||
|
|
||
| const WebSearchScopes = { | ||
| ...RequiredScopes, | ||
| 'account:read': 'See your account info such as account details, analytics, and memberships.', | ||
| 'websearch.run': 'Grants access to run Cloudflare Web Search queries.', | ||
| } as const | ||
|
|
||
| // Stateless: createMcpHandler needs a fresh server per request. AccountManager + buildAccountTool | ||
| // give the same account resolution (auth-pinned → cf-account-id header → account_id arg) the | ||
| // McpAgent servers get from CloudflareMCPServer.accountTool(). | ||
| // TODO(RAG-1300): tool-call metrics are not yet tracked for the stateless server. | ||
| function createServer(env: Env, props: AuthProps): McpServer { | ||
| const accountManager = new AccountManager(props) | ||
| const server = new McpServer( | ||
| { name: env.MCP_SERVER_NAME, version: env.MCP_SERVER_VERSION }, | ||
| { instructions: `${BASE_INSTRUCTIONS}${accountManager.instructionsSuffix()}` } | ||
| ) | ||
| registerWebSearchTools(server, accountManager, props.accessToken) | ||
| return server | ||
| } | ||
|
|
||
| // Stateless streamable-HTTP handler. Reads the AuthProps set by the OAuthProvider (OAuth mode) | ||
| // or by the API-token branch below, then builds a fresh server for the request. | ||
| function mcpFetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | ||
| const { props } = ctx as { props: AuthProps } | ||
| const handler = createMcpHandler(createServer(env, props), { route: '/mcp' }) | ||
| return handler(req, env, ctx) | ||
| } | ||
|
|
||
| // Resolve a raw Cloudflare API token into the same AuthProps shape the OAuth flow produces, so | ||
| // AccountManager can pin/validate the account (mirrors mcp-common's handleApiTokenMode). | ||
| async function buildApiTokenProps(req: Request, env: Env): Promise<AuthProps> { | ||
| let devModeHeaders: HeadersInit | undefined | ||
| let token: string | ||
| if (env.DEV_CLOUDFLARE_API_TOKEN && env.DEV_DISABLE_OAUTH === 'true') { | ||
| devModeHeaders = { Authorization: `Bearer ${env.DEV_CLOUDFLARE_API_TOKEN}` } | ||
| token = env.DEV_CLOUDFLARE_API_TOKEN | ||
| } else { | ||
| const [, bearer] = (req.headers.get('Authorization') ?? '').split(' ') | ||
| token = bearer ?? '' | ||
| } | ||
|
|
||
| const { user, accounts } = await getUserAndAccounts(token, devModeHeaders) | ||
| if (user === null) { | ||
| return { type: 'account_token', accessToken: token, account: accounts[0] } | ||
| } | ||
| return { type: 'user_token', accessToken: token, user, accounts } | ||
| } | ||
|
|
||
| export default { | ||
| fetch: async (req: Request, env: Env, ctx: ExecutionContext): Promise<Response> => { | ||
| // Raw Cloudflare API token bearer → skip OAuth, use it directly. | ||
| // OAuth-issued tokens are excluded by isApiTokenRequest and fall through. | ||
| if (await isApiTokenRequest(req, env)) { | ||
| // `ExecutionContext.props` is typed readonly, but the handler reads the props we | ||
| // set here before the request is served, so assign through a typed mutable view. | ||
| const ctxWithProps = ctx as { props: AuthProps } | ||
| ctxWithProps.props = await buildApiTokenProps(req, env) | ||
| return mcpFetch(req, env, ctx) | ||
| } | ||
|
|
||
| // OAuth mode: advertises the OAuth endpoints and decrypts OAuth-issued | ||
| // tokens into ctx.props before delegating to the MCP handler. | ||
| return new OAuthProvider({ | ||
| apiHandlers: { | ||
| '/mcp': { fetch: mcpFetch }, | ||
| }, | ||
| defaultHandler: createAuthHandlers({ scopes: WebSearchScopes, metrics }), | ||
| authorizeEndpoint: '/oauth/authorize', | ||
| tokenEndpoint: '/token', | ||
| tokenExchangeCallback: (options) => | ||
| handleTokenExchangeCallback( | ||
| options, | ||
| env.CLOUDFLARE_CLIENT_ID, | ||
| env.CLOUDFLARE_CLIENT_SECRET | ||
| ), | ||
| // Cloudflare access token TTL | ||
| accessTokenTTL: 3600, | ||
| refreshTokenTTL: 2592000, // 30 days | ||
| clientRegistrationEndpoint: '/register', | ||
| }).fetch(req, env, ctx) | ||
| }, | ||
| } |
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,26 @@ | ||
| export interface Env { | ||
| OAUTH_KV: KVNamespace | ||
| MCP_COOKIE_ENCRYPTION_KEY: string | ||
| ENVIRONMENT: 'development' | 'staging' | 'production' | ||
| MCP_SERVER_NAME: string | ||
| MCP_SERVER_VERSION: string | ||
| CLOUDFLARE_CLIENT_ID: string | ||
| CLOUDFLARE_CLIENT_SECRET: string | ||
| MCP_METRICS: AnalyticsEngineDataset | ||
| GIT_HASH: string | ||
| DEV_DISABLE_OAUTH: string | ||
| DEV_CLOUDFLARE_API_TOKEN: string | ||
| DEV_CLOUDFLARE_EMAIL: string | ||
| } | ||
|
|
||
| export const BASE_INSTRUCTIONS = /* markdown */ ` | ||
| # Cloudflare Web Search MCP Server | ||
|
|
||
| This server provides a web search **discovery** tool powered by the Cloudflare Web Search API. | ||
|
|
||
| It does **not** return the contents of pages. It returns a ranked list of **links**, each with related metadata such as title, description, image, and other fields. After running a search, review the results, decide which links are relevant to your task, and fetch those URLs yourself to read their contents. | ||
|
|
||
| ## Tools | ||
|
|
||
| - **web_search**: Run a web search query and return ranked links, each with metadata (title, description, image, and more) — not page contents. The Cloudflare account is resolved from your credentials; if they span multiple accounts you'll be asked to pass an \`account_id\`. | ||
| ` |
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,4 @@ | ||
| { | ||
| "extends": "@repo/typescript-config/workers.json", | ||
| "include": ["*/**.ts", "./vitest.config.ts", "./types.d.ts"] | ||
| } |
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,5 @@ | ||
| import type { TestEnv } from './vitest.config' | ||
|
|
||
| declare module 'cloudflare:test' { | ||
| interface ProvidedEnv extends TestEnv {} | ||
| } | ||
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,25 @@ | ||
| import { cloudflareTest } from '@cloudflare/vitest-pool-workers' | ||
| import { defineConfig } from 'vitest/config' | ||
|
|
||
| import type { Env } from './src/websearch.context' | ||
|
|
||
| export interface TestEnv extends Env { | ||
| CLOUDFLARE_MOCK_ACCOUNT_ID: string | ||
| CLOUDFLARE_MOCK_API_TOKEN: string | ||
| } | ||
|
|
||
| export default defineConfig({ | ||
| plugins: [ | ||
| cloudflareTest({ | ||
| wrangler: { configPath: `${__dirname}/wrangler.jsonc` }, | ||
| miniflare: { | ||
| bindings: { | ||
| CLOUDFLARE_MOCK_ACCOUNT_ID: 'mock-account-id', | ||
| CLOUDFLARE_MOCK_API_TOKEN: 'mock-api-token', | ||
| } satisfies Partial<TestEnv>, | ||
| }, | ||
| }), | ||
| ], | ||
|
|
||
| test: {}, | ||
| }) |
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this might need to change with the upgrade to vitest 4