Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/websearch-mcp-server.md
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.
5 changes: 5 additions & 0 deletions apps/websearch/.dev.vars.example
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=
5 changes: 5 additions & 0 deletions apps/websearch/.eslintrc.cjs
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'],
}
59 changes: 59 additions & 0 deletions apps/websearch/README.md
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.
34 changes: 34 additions & 0 deletions apps/websearch/package.json
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"
}
72 changes: 72 additions & 0 deletions apps/websearch/src/tools/websearch.tools.ts
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
)
}
110 changes: 110 additions & 0 deletions apps/websearch/src/websearch.app.ts
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)
},
}
26 changes: 26 additions & 0 deletions apps/websearch/src/websearch.context.ts
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\`.
`
4 changes: 4 additions & 0 deletions apps/websearch/tsconfig.json
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"]
}
5 changes: 5 additions & 0 deletions apps/websearch/types.d.ts
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 {}
}
Comment on lines +1 to +5

Copy link
Copy Markdown
Collaborator

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

25 changes: 25 additions & 0 deletions apps/websearch/vitest.config.ts
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: {},
})
Loading
Loading