Skip to content

Commit 80e6dcf

Browse files
committed
feat: add Web Search MCP server
1 parent f625075 commit 80e6dcf

15 files changed

Lines changed: 14949 additions & 82 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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 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.
7+
8+
## 🔨 Available Tools
9+
10+
Currently available tools:
11+
12+
| **Category** | **Tool** | **Description** |
13+
| -------------- | ------------ | ------------------------------------------------------------------------------------------------------------- |
14+
| **Web Search** | `web_search` | Discovery tool — returns ranked links with metadata (title, description, image, and more), not page contents. |
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+
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.
26+
27+
For clients that use a JSON config file, add it as a remote server:
28+
29+
```json
30+
{
31+
"mcpServers": {
32+
"cloudflare-websearch": {
33+
"url": "https://websearch.mcp.cloudflare.com/mcp"
34+
}
35+
}
36+
}
37+
```
38+
39+
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:
40+
41+
```json
42+
{
43+
"mcpServers": {
44+
"cloudflare-websearch": {
45+
"url": "https://websearch.mcp.cloudflare.com/mcp",
46+
"headers": {
47+
"Authorization": "Bearer <your-cloudflare-api-token>"
48+
}
49+
}
50+
}
51+
}
52+
```
53+
54+
## Authentication
55+
56+
This server supports two authentication modes:
57+
58+
- **OAuth** — connect to the server URL and complete the Cloudflare OAuth flow.
59+
- **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",
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+
"zod": "4.4.3"
24+
},
25+
"devDependencies": {
26+
"@cloudflare/vitest-pool-workers": "0.16.11",
27+
"@types/node": "22.15.17",
28+
"prettier": "3.5.3",
29+
"typescript": "5.5.4",
30+
"vitest": "4.1.8",
31+
"wrangler": "4.96.0"
32+
},
33+
"type": "module"
34+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { z } from 'zod'
2+
3+
import { buildAccountTool } from '@repo/mcp-common/src/account-tool'
4+
5+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
6+
import type { AccountManager } from '@repo/mcp-common/src/account-manager'
7+
8+
const WEBSEARCH_API_BASE = 'https://api.cloudflare.com/client/v4/accounts'
9+
10+
const QueryParam = z.string().min(1).describe('The web search query to run.')
11+
// Not range-validated here on purpose — the Web Search API enforces the cap (20).
12+
const MaxResultsParam = z
13+
.number()
14+
.int()
15+
.optional()
16+
.describe('Maximum number of results to return (up to 20).')
17+
18+
// `account_id` is appended by buildAccountTool only when the credentials span multiple accounts;
19+
// otherwise the account is resolved from auth (cf-account-id header → account_id arg fallback).
20+
export function registerWebSearchTools(
21+
server: McpServer,
22+
accountManager: AccountManager,
23+
accessToken: string
24+
) {
25+
const { shape, callback } = buildAccountTool(
26+
accountManager,
27+
{ query: QueryParam, max_results: MaxResultsParam },
28+
async ({ query, max_results }, accountId) => {
29+
try {
30+
const res = await fetch(`${WEBSEARCH_API_BASE}/${accountId}/websearch/search`, {
31+
method: 'POST',
32+
headers: {
33+
Authorization: `Bearer ${accessToken}`,
34+
'Content-Type': 'application/json',
35+
},
36+
body: JSON.stringify({ query, max_results }),
37+
})
38+
39+
if (!res.ok) {
40+
const errorData = await res.json().catch(() => ({}))
41+
throw new Error(`Web search failed: ${res.status} ${JSON.stringify(errorData)}`)
42+
}
43+
44+
const data = await res.json()
45+
return {
46+
content: [{ type: 'text' as const, text: JSON.stringify(data) }],
47+
}
48+
} catch (error) {
49+
return {
50+
isError: true,
51+
content: [
52+
{
53+
type: 'text' as const,
54+
text: `Error running web search: ${error instanceof Error ? error.message : String(error)}`,
55+
},
56+
],
57+
}
58+
}
59+
}
60+
)
61+
62+
server.registerTool(
63+
'web_search',
64+
{
65+
description:
66+
'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.',
67+
inputSchema: shape,
68+
annotations: { readOnlyHint: true },
69+
},
70+
callback
71+
)
72+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 { AccountManager } from '@repo/mcp-common/src/account-manager'
6+
import { isApiTokenRequest } from '@repo/mcp-common/src/api-token-mode'
7+
import {
8+
createAuthHandlers,
9+
getUserAndAccounts,
10+
handleTokenExchangeCallback,
11+
} from '@repo/mcp-common/src/cloudflare-oauth-handler'
12+
import { getEnv } from '@repo/mcp-common/src/env'
13+
import { RequiredScopes } from '@repo/mcp-common/src/scopes'
14+
import { MetricsTracker } from '@repo/mcp-observability'
15+
16+
import { registerWebSearchTools } from './tools/websearch.tools'
17+
import { BASE_INSTRUCTIONS } from './websearch.context'
18+
19+
import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler'
20+
import type { Env } from './websearch.context'
21+
22+
const env = getEnv<Env>()
23+
24+
const metrics = new MetricsTracker(env.MCP_METRICS, {
25+
name: env.MCP_SERVER_NAME,
26+
version: env.MCP_SERVER_VERSION,
27+
})
28+
29+
const WebSearchScopes = {
30+
...RequiredScopes,
31+
'account:read': 'See your account info such as account details, analytics, and memberships.',
32+
'websearch.run': 'Grants access to run Cloudflare Web Search queries.',
33+
} as const
34+
35+
// Stateless: createMcpHandler needs a fresh server per request. AccountManager + buildAccountTool
36+
// give the same account resolution (auth-pinned → cf-account-id header → account_id arg) the
37+
// McpAgent servers get from CloudflareMCPServer.accountTool().
38+
// TODO(RAG-1300): tool-call metrics are not yet tracked for the stateless server.
39+
function createServer(env: Env, props: AuthProps): McpServer {
40+
const accountManager = new AccountManager(props)
41+
const server = new McpServer(
42+
{ name: env.MCP_SERVER_NAME, version: env.MCP_SERVER_VERSION },
43+
{ instructions: `${BASE_INSTRUCTIONS}${accountManager.instructionsSuffix()}` }
44+
)
45+
registerWebSearchTools(server, accountManager, props.accessToken)
46+
return server
47+
}
48+
49+
// Stateless streamable-HTTP handler. Reads the AuthProps set by the OAuthProvider (OAuth mode)
50+
// or by the API-token branch below, then builds a fresh server for the request.
51+
function mcpFetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
52+
const { props } = ctx as { props: AuthProps }
53+
const handler = createMcpHandler(createServer(env, props), { route: '/mcp' })
54+
return handler(req, env, ctx)
55+
}
56+
57+
// Resolve a raw Cloudflare API token into the same AuthProps shape the OAuth flow produces, so
58+
// AccountManager can pin/validate the account (mirrors mcp-common's handleApiTokenMode).
59+
async function buildApiTokenProps(req: Request, env: Env): Promise<AuthProps> {
60+
let devModeHeaders: HeadersInit | undefined
61+
let token: string
62+
if (env.DEV_CLOUDFLARE_API_TOKEN && env.DEV_DISABLE_OAUTH === 'true') {
63+
devModeHeaders = { Authorization: `Bearer ${env.DEV_CLOUDFLARE_API_TOKEN}` }
64+
token = env.DEV_CLOUDFLARE_API_TOKEN
65+
} else {
66+
const [, bearer] = (req.headers.get('Authorization') ?? '').split(' ')
67+
token = bearer ?? ''
68+
}
69+
70+
const { user, accounts } = await getUserAndAccounts(token, devModeHeaders)
71+
if (user === null) {
72+
return { type: 'account_token', accessToken: token, account: accounts[0] }
73+
}
74+
return { type: 'user_token', accessToken: token, user, accounts }
75+
}
76+
77+
export default {
78+
fetch: async (req: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
79+
// Raw Cloudflare API token bearer → skip OAuth, use it directly.
80+
// OAuth-issued tokens are excluded by isApiTokenRequest and fall through.
81+
if (await isApiTokenRequest(req, env)) {
82+
// `ExecutionContext.props` is typed readonly, but the handler reads the props we
83+
// set here before the request is served, so assign through a typed mutable view.
84+
const ctxWithProps = ctx as { props: AuthProps }
85+
ctxWithProps.props = await buildApiTokenProps(req, env)
86+
return mcpFetch(req, env, ctx)
87+
}
88+
89+
// OAuth mode: advertises the OAuth endpoints and decrypts OAuth-issued
90+
// tokens into ctx.props before delegating to the MCP handler.
91+
return new OAuthProvider({
92+
apiHandlers: {
93+
'/mcp': { fetch: mcpFetch },
94+
},
95+
defaultHandler: createAuthHandlers({ scopes: WebSearchScopes, metrics }),
96+
authorizeEndpoint: '/oauth/authorize',
97+
tokenEndpoint: '/token',
98+
tokenExchangeCallback: (options) =>
99+
handleTokenExchangeCallback(
100+
options,
101+
env.CLOUDFLARE_CLIENT_ID,
102+
env.CLOUDFLARE_CLIENT_SECRET
103+
),
104+
// Cloudflare access token TTL
105+
accessTokenTTL: 3600,
106+
refreshTokenTTL: 2592000, // 30 days
107+
clientRegistrationEndpoint: '/register',
108+
}).fetch(req, env, ctx)
109+
},
110+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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 web search **discovery** tool powered by the Cloudflare Web Search API.
20+
21+
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.
22+
23+
## Tools
24+
25+
- **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\`.
26+
`

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)