Skip to content

Commit f41f54b

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

15 files changed

Lines changed: 7274 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_id` 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_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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { getMcpAuthContext } from 'agents/mcp'
2+
import { z } from 'zod'
3+
4+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5+
6+
const WEBSEARCH_API_BASE = 'https://api.cloudflare.com/client/v4/accounts'
7+
8+
const QueryParam = z.string().min(1).describe('The web search query to run.')
9+
const AccountIdParam = z
10+
.string()
11+
.min(1)
12+
.describe('The Cloudflare account id to run the web search under.')
13+
// Not range-validated here on purpose — the Web Search API enforces the cap (20).
14+
const MaxResultsParam = z
15+
.number()
16+
.int()
17+
.optional()
18+
.describe('Maximum number of results to return (up to 20).')
19+
20+
export function registerWebSearchTools(server: McpServer) {
21+
server.tool(
22+
'web_search',
23+
'Run a web search query via the Cloudflare Web Search API and return ranked results (title, url, snippet).',
24+
{
25+
query: QueryParam,
26+
account_id: AccountIdParam,
27+
max_results: MaxResultsParam,
28+
},
29+
async ({ query, account_id, max_results }) => {
30+
const accessToken = getMcpAuthContext()?.props?.accessToken
31+
if (typeof accessToken !== 'string' || accessToken.length === 0) {
32+
return {
33+
content: [
34+
{
35+
type: 'text' as const,
36+
text: 'No access token available. Authenticate via OAuth or send a Cloudflare API token as a Bearer token.',
37+
},
38+
],
39+
}
40+
}
41+
42+
try {
43+
const res = await fetch(`${WEBSEARCH_API_BASE}/${account_id}/websearch/search`, {
44+
method: 'POST',
45+
headers: {
46+
Authorization: `Bearer ${accessToken}`,
47+
'Content-Type': 'application/json',
48+
},
49+
body: JSON.stringify({ query, max_results }),
50+
})
51+
52+
if (!res.ok) {
53+
const errorData = await res.json().catch(() => ({}))
54+
throw new Error(`Web search failed: ${res.status} ${JSON.stringify(errorData)}`)
55+
}
56+
57+
const data = await res.json()
58+
return {
59+
content: [
60+
{
61+
type: 'text' as const,
62+
text: JSON.stringify(data),
63+
},
64+
],
65+
}
66+
} catch (error) {
67+
return {
68+
content: [
69+
{
70+
type: 'text' as const,
71+
text: `Error running web search: ${error instanceof Error ? error.message : String(error)}`,
72+
},
73+
],
74+
}
75+
}
76+
}
77+
)
78+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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): add stateless-server support to CloudflareMCPServer/metrics so this
34+
// server can report tool-call metrics (and per-user userId via getMcpAuthContext).
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+
clientRegistrationEndpoint: '/register',
87+
}).fetch(req, env, ctx)
88+
},
89+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
## Tools
22+
23+
- **web_search**: Run a web search query for a given account and return ranked results (title, url, snippet). Pass the Cloudflare account id via the \`account_id\` parameter.
24+
`

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)