Skip to content

Commit b08df33

Browse files
committed
feat: add Web Search MCP server
1 parent 792f5ff commit b08df33

15 files changed

Lines changed: 7282 additions & 50 deletions

.changeset/websearch-mcp-server.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"websearch": minor
3+
---
4+
5+
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_tag` parameter and is gated behind the `websearch.run` OAuth scope. Supports both OAuth and raw Cloudflare API token (Bearer) authentication.

apps/websearch/.dev.vars.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CLOUDFLARE_CLIENT_ID=
2+
CLOUDFLARE_CLIENT_SECRET=
3+
DEV_DISABLE_OAUTH=
4+
DEV_CLOUDFLARE_API_TOKEN=

apps/websearch/.eslintrc.cjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** @type {import("eslint").Linter.Config} */
2+
module.exports = {
3+
root: true,
4+
extends: ['@repo/eslint-config/default.cjs'],
5+
}

apps/websearch/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Web Search MCP Server 🔎
2+
3+
This is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that supports remote MCP
4+
connections, with Cloudflare OAuth built-in.
5+
6+
It integrates a tool powered by the Cloudflare Web Search API to run web search queries and return ranked results.
7+
8+
## 🔨 Available Tools
9+
10+
Currently available tools:
11+
12+
| **Category** | **Tool** | **Description** |
13+
| -------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
14+
| **Web Search** | `web_search` | Runs a web search query via the Cloudflare Web Search API and returns ranked results (title, url, snippet). Takes an `account_tag` (account id) parameter. |
15+
16+
This MCP server is still a work in progress, and we plan to add more tools in the future.
17+
18+
### Prompt Examples
19+
20+
- `Search the web for the latest Cloudflare Workers release notes`
21+
- `Find recent articles about MCP servers`
22+
23+
## Access the remote MCP server from any MCP Client
24+
25+
If your MCP client has first class support for remote MCP servers, the client will provide a way to accept the server URL (`https://websearch.mcp.cloudflare.com`) directly within its interface (for example in [Cloudflare AI Playground](https://playground.ai.cloudflare.com/)).
26+
27+
If your client does not yet support remote MCP servers, you will need to set up its respective configuration file using [mcp-remote](https://www.npmjs.com/package/mcp-remote) to specify which servers your client can access.
28+
29+
Replace the content with the following configuration:
30+
31+
```json
32+
{
33+
"mcpServers": {
34+
"cloudflare": {
35+
"command": "npx",
36+
"args": ["mcp-remote", "https://websearch.mcp.cloudflare.com/mcp"]
37+
}
38+
}
39+
}
40+
```
41+
42+
Once you've set up your configuration file, restart your MCP client and a browser window will open showing your OAuth login page. Complete the authentication flow to grant the MCP server access to your Cloudflare account. Once authenticated, you'll be able to run web searches through your MCP client.
43+
44+
## Authentication
45+
46+
This server supports two authentication modes:
47+
48+
- **OAuth** — connect to the server URL and complete the Cloudflare OAuth flow.
49+
- **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.

apps/websearch/package.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "websearch",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"check:lint": "run-eslint-workers",
7+
"check:types": "run-tsc",
8+
"deploy": "run-wrangler-deploy",
9+
"dev": "wrangler dev",
10+
"start": "wrangler dev",
11+
"types": "wrangler types --include-env=false",
12+
"test": "vitest run"
13+
},
14+
"dependencies": {
15+
"@cloudflare/workers-oauth-provider": "0.4.0",
16+
"@hono/zod-validator": "0.4.3",
17+
"@modelcontextprotocol/sdk": "1.29.0",
18+
"@repo/mcp-common": "workspace:*",
19+
"@repo/mcp-observability": "workspace:*",
20+
"agents": "0.13.3",
21+
"cloudflare": "4.2.0",
22+
"hono": "4.7.6",
23+
"workers-tagged-logger": "0.13.5",
24+
"zod": "3.25.76"
25+
},
26+
"devDependencies": {
27+
"@cloudflare/vitest-pool-workers": "0.8.14",
28+
"@types/node": "22.14.1",
29+
"prettier": "3.5.3",
30+
"typescript": "5.5.4",
31+
"vitest": "3.0.9",
32+
"wrangler": "4.10.0"
33+
}
34+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { getMcpAuthContext } from 'agents/mcp'
2+
import { z } from 'zod'
3+
4+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5+
6+
// TODO(RAG-1300): confirm the canonical Web Search API path before launch.
7+
const WEBSEARCH_API_BASE = 'https://api.cloudflare.com/client/v4/accounts'
8+
9+
const QueryParam = z.string().min(1).describe('The web search query to run.')
10+
const AccountTagParam = z
11+
.string()
12+
.min(1)
13+
.describe('The Cloudflare account tag (account id) to run the web search under.')
14+
const MaxResultsParam = z
15+
.number()
16+
.int()
17+
.min(1)
18+
.max(50)
19+
.optional()
20+
.describe('Maximum number of results to return (1-50).')
21+
22+
export function registerWebSearchTools(server: McpServer) {
23+
server.tool(
24+
'web_search',
25+
'Run a web search query via the Cloudflare Web Search API and return ranked results (title, url, snippet).',
26+
{
27+
query: QueryParam,
28+
account_tag: AccountTagParam,
29+
max_results: MaxResultsParam,
30+
},
31+
async ({ query, account_tag, max_results }) => {
32+
const accessToken = getMcpAuthContext()?.props?.accessToken
33+
if (typeof accessToken !== 'string' || accessToken.length === 0) {
34+
return {
35+
content: [
36+
{
37+
type: 'text' as const,
38+
text: 'No access token available. Authenticate via OAuth or send a Cloudflare API token as a Bearer token.',
39+
},
40+
],
41+
}
42+
}
43+
44+
try {
45+
const res = await fetch(`${WEBSEARCH_API_BASE}/${account_tag}/websearch/search`, {
46+
method: 'POST',
47+
headers: {
48+
Authorization: `Bearer ${accessToken}`,
49+
'Content-Type': 'application/json',
50+
},
51+
body: JSON.stringify({ query, max_results }),
52+
})
53+
54+
if (!res.ok) {
55+
const errorData = await res.json().catch(() => ({}))
56+
throw new Error(`Web search failed: ${res.status} ${JSON.stringify(errorData)}`)
57+
}
58+
59+
const data = await res.json()
60+
return {
61+
content: [
62+
{
63+
type: 'text' as const,
64+
text: JSON.stringify(data),
65+
},
66+
],
67+
}
68+
} catch (error) {
69+
return {
70+
content: [
71+
{
72+
type: 'text' as const,
73+
text: `Error running web search: ${error instanceof Error ? error.message : String(error)}`,
74+
},
75+
],
76+
}
77+
}
78+
}
79+
)
80+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import OAuthProvider from '@cloudflare/workers-oauth-provider'
2+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3+
import { createMcpHandler } from 'agents/mcp'
4+
5+
import { isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode'
6+
import {
7+
createAuthHandlers,
8+
handleTokenExchangeCallback,
9+
} from '@repo/mcp-common/src/cloudflare-oauth-handler'
10+
import { getEnv } from '@repo/mcp-common/src/env'
11+
import { RequiredScopes } from '@repo/mcp-common/src/scopes'
12+
import { MetricsTracker } from '@repo/mcp-observability'
13+
14+
import { registerWebSearchTools } from './tools/websearch.tools'
15+
import { BASE_INSTRUCTIONS } from './websearch.context'
16+
17+
import type { Env } from './websearch.context'
18+
19+
const env = getEnv<Env>()
20+
21+
const metrics = new MetricsTracker(env.MCP_METRICS, {
22+
name: env.MCP_SERVER_NAME,
23+
version: env.MCP_SERVER_VERSION,
24+
})
25+
26+
const WebSearchScopes = {
27+
...RequiredScopes,
28+
'account:read': 'See your account info such as account details, analytics, and memberships.',
29+
'websearch.run': 'Grants access to run Cloudflare Web Search queries.',
30+
} as const
31+
32+
// Stateless: createMcpHandler requires a fresh server instance per request.
33+
// TODO(RAG-1300): swap back to CloudflareMCPServer (metrics + per-user userId via
34+
// getMcpAuthContext) once @repo/mcp-common is on a zod 3.25+/SDK 1.29+ compatible stack.
35+
function createServer(env: Env): McpServer {
36+
const server = new McpServer(
37+
{ name: env.MCP_SERVER_NAME, version: env.MCP_SERVER_VERSION },
38+
{ instructions: BASE_INSTRUCTIONS }
39+
)
40+
registerWebSearchTools(server)
41+
return server
42+
}
43+
44+
// Stateless streamable-HTTP handler. Reads the access token from ctx.props,
45+
// set either by the OAuthProvider (OAuth mode) or below (API-token mode).
46+
function mcpFetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
47+
const handler = createMcpHandler(createServer(env), { route: '/mcp' })
48+
return handler(req, env, ctx)
49+
}
50+
51+
function getBearerToken(req: Request, env: Env): string {
52+
if (env.DEV_CLOUDFLARE_API_TOKEN && env.DEV_DISABLE_OAUTH === 'true') {
53+
return env.DEV_CLOUDFLARE_API_TOKEN
54+
}
55+
const [, token] = (req.headers.get('Authorization') ?? '').split(' ')
56+
return token ?? ''
57+
}
58+
59+
export default {
60+
fetch: async (req: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
61+
// Raw Cloudflare API token bearer → skip OAuth, use it directly.
62+
// OAuth-issued tokens are excluded by isApiTokenRequest and fall through.
63+
if (await isApiTokenRequest(req, env)) {
64+
ctx.props = { type: 'api_token', accessToken: getBearerToken(req, env) }
65+
return mcpFetch(req, env, ctx)
66+
}
67+
68+
// OAuth mode: advertises the OAuth endpoints and decrypts OAuth-issued
69+
// tokens into ctx.props.accessToken before delegating to the MCP handler.
70+
return new OAuthProvider({
71+
apiHandlers: {
72+
'/mcp': { fetch: mcpFetch },
73+
},
74+
defaultHandler: createAuthHandlers({ scopes: WebSearchScopes, metrics }),
75+
authorizeEndpoint: '/oauth/authorize',
76+
tokenEndpoint: '/token',
77+
tokenExchangeCallback: (options) =>
78+
handleTokenExchangeCallback(
79+
options,
80+
env.CLOUDFLARE_CLIENT_ID,
81+
env.CLOUDFLARE_CLIENT_SECRET
82+
),
83+
// Cloudflare access token TTL
84+
accessTokenTTL: 3600,
85+
refreshTokenTTL: 2592000, // 30 days
86+
// TODO: Remove after 2026-05-01 — all pre-0.4.0 grants will have expired by then
87+
resourceMatchOriginOnly: true,
88+
clientRegistrationEndpoint: '/register',
89+
}).fetch(req, env, ctx)
90+
},
91+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export interface Env {
2+
OAUTH_KV: KVNamespace
3+
MCP_COOKIE_ENCRYPTION_KEY: string
4+
ENVIRONMENT: 'development' | 'staging' | 'production'
5+
MCP_SERVER_NAME: string
6+
MCP_SERVER_VERSION: string
7+
CLOUDFLARE_CLIENT_ID: string
8+
CLOUDFLARE_CLIENT_SECRET: string
9+
MCP_METRICS: AnalyticsEngineDataset
10+
GIT_HASH: string
11+
DEV_DISABLE_OAUTH: string
12+
DEV_CLOUDFLARE_API_TOKEN: string
13+
DEV_CLOUDFLARE_EMAIL: string
14+
}
15+
16+
export const BASE_INSTRUCTIONS = /* markdown */ `
17+
# Cloudflare Web Search MCP Server
18+
19+
This server provides a tool to run web searches powered by the Cloudflare Web Search API.
20+
21+
## Authentication
22+
23+
- Connect with OAuth, or send a Cloudflare API token as a Bearer token to skip OAuth.
24+
25+
## Tools
26+
27+
- **web_search**: Run a web search query for a given account and return ranked results (title, url, snippet). Pass the Cloudflare account tag via the \`account_tag\` parameter.
28+
`

apps/websearch/tsconfig.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "@repo/typescript-config/workers.json",
3+
"include": ["*/**.ts", "./vitest.config.ts", "./types.d.ts"]
4+
}

apps/websearch/types.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { TestEnv } from './vitest.config'
2+
3+
declare module 'cloudflare:test' {
4+
interface ProvidedEnv extends TestEnv {}
5+
}

0 commit comments

Comments
 (0)