Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions apps/template-start-here/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CLOUDFLARE_CLIENT_ID=
CLOUDFLARE_CLIENT_SECRET=
5 changes: 5 additions & 0 deletions apps/template-start-here/.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'],
}
37 changes: 37 additions & 0 deletions apps/template-start-here/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Model Context Protocol (MCP) Server + Cloudflare OAuth

This is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that supports remote MCP connections, with Cloudflare OAuth built-in.

You should use this as a template to build an MCP server for Cloudflare, provided by Cloudflare at `server-name.mcp.cloudflare.com`.

## Getting Started

### For Production

- Set secrets via Wrangler (ask in the `Cloudflare's Own MCP Servers` channel to get credentials)

```bash
wrangler secret put CLOUDFLARE_CLIENT_ID
wrangler secret put CLOUDFLARE_CLIENT_SECRET
```

#### Set up a KV namespace

- Create the KV namespace:
`wrangler kv:namespace create "OAUTH_KV"`
- Update the Wrangler file with the KV ID

#### Deploy & Test

Deploy the MCP server to make it available on your workers.dev domain
` wrangler deploy`

Test the remote server using [Inspector](https://modelcontextprotocol.io/docs/tools/inspector):

```
npx @modelcontextprotocol/inspector@latest
```

## Deploying to production

- You will need to liberate the zone (LTZ) for your `<server-name>.mcp.cloudflare.com`
33 changes: 33 additions & 0 deletions apps/template-start-here/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "workers-observability",
"version": "0.0.1",
"private": true,
"scripts": {
"check:lint": "run-eslint-workers",
"check:types": "run-tsc",
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"cf-typegen": "wrangler types",
"test": "vitest run"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "0.0.2",
"@hono/zod-validator": "0.4.3",
"@modelcontextprotocol/sdk": "1.8.0",
"@repo/mcp-common": "workspace:*",
"agents": "0.0.49",
"cloudflare": "4.2.0",
"hono": "4.7.6",
"zod": "3.24.2"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.8.14",
"@cloudflare/workers-types": "4.20250410.0",
"@types/jsonwebtoken": "9.0.9",
"prettier": "3.5.3",
"typescript": "5.5.4",
"vitest": "3.0.9",
"wrangler": "4.10.0"
}
}
80 changes: 80 additions & 0 deletions apps/template-start-here/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import OAuthProvider from '@cloudflare/workers-oauth-provider'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { McpAgent } from 'agents/mcp'
import { env } from 'cloudflare:workers'

import {
CloudflareAuthHandler,
handleTokenExchangeCallback,
} from '@repo/mcp-common/src/cloudflare-oauth-handler'
import { registerAccountTools } from '@repo/mcp-common/src/tools/account'

// EXAMPLE TOOLS — Edit this to create your own tools
import { registerLogsTools } from './tools/logs'

import type { AccountSchema, UserSchema } from '@repo/mcp-common/src/cloudflare-oauth-handler'

// Context from the auth process, encrypted & stored in the auth token
// and provided to the DurableMCP as this.props
export type Props = {
accessToken: string
user: UserSchema['result']
accounts: AccountSchema['result']
}

export type State = { activeAccountId: string | null }

export class MyMCP extends McpAgent<Env, State, Props> {
server = new McpServer({
name: 'Remote MCP Server with Workers Observability',
version: '1.0.0',
})

initialState: State = {
activeAccountId: null,
}

async init() {
registerAccountTools(this)

// EXAMPLE TOOLS — register your own here
registerLogsTools(this)

}

getActiveAccountId() {
// TODO: Figure out why this fail sometimes, and why we need to wrap this in a try catch
try {
return this.state.activeAccountId ?? null
} catch (e) {
return null
}
}

setActiveAccountId(accountId: string) {
// TODO: Figure out why this fail sometimes, and why we need to wrap this in a try catch
try {
this.setState({
...this.state,
activeAccountId: accountId,
})
} catch (e) {
return null
}
}
}

export default new OAuthProvider({
apiRoute: '/workers/observability/sse',
// @ts-ignore
apiHandler: MyMCP.mount('/workers/observability/sse'),
// @ts-ignore
defaultHandler: CloudflareAuthHandler,
authorizeEndpoint: '/oauth/authorize',
tokenEndpoint: '/token',
tokenExchangeCallback: (options) =>
handleTokenExchangeCallback(options, env.CLOUDFLARE_CLIENT_ID, env.CLOUDFLARE_CLIENT_SECRET),
// Cloudflare access token TTL
accessTokenTTL: 3600,
clientRegistrationEndpoint: '/register',
})
225 changes: 225 additions & 0 deletions apps/template-start-here/src/tools/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// EXAMPLE TOOLS — Edit this to create your own tools

import { z } from 'zod'

import { handleWorkerLogs, handleWorkerLogsKeys } from '@repo/mcp-common/src/api/workers-logs'

import type { MyMCP } from '../index'

// Worker logs parameter schema
const workerNameParam = z.string().describe('The name of the worker to analyze logs for')
const filterErrorsParam = z.boolean().default(false).describe('If true, only shows error logs')
const limitParam = z
.number()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of logs to retrieve (1-100, default 100)')
const minutesAgoParam = z
.number()
.min(1)
.max(10080)
.default(30)
.describe('Minutes in the past to look for logs (1-10080, default 30)')
const rayIdParam = z.string().optional().describe('Filter logs by specific Cloudflare Ray ID')

/**
* Registers the logs analysis tool with the MCP server
* @param server The MCP server instance
* @param accountId Cloudflare account ID
* @param apiToken Cloudflare API token
*/
export function registerLogsTools(agent: MyMCP) {
// Register the worker logs analysis tool by worker name
agent.server.tool(
'worker_logs_by_worker_name',
'Analyze recent logs for a Cloudflare Worker by worker name',
{
scriptName: workerNameParam,
shouldFilterErrors: filterErrorsParam,
limitParam,
minutesAgoParam,
rayId: rayIdParam,
},
async (params) => {
const accountId = agent.getActiveAccountId()
if (!accountId) {
return {
content: [
{
type: 'text',
text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
},
],
}
}
try {
const { scriptName, shouldFilterErrors, limitParam, minutesAgoParam, rayId } = params
const { logs, from, to } = await handleWorkerLogs({
scriptName,
limit: limitParam,
minutesAgo: minutesAgoParam,
accountId,
apiToken: agent.props.accessToken,
shouldFilterErrors,
rayId,
})
return {
content: [
{
type: 'text',
text: JSON.stringify({
logs,
stats: {
timeRange: {
from,
to,
},
},
}),
},
],
}
} catch (error) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: `Error analyzing worker logs: ${error instanceof Error && error.message}`,
}),
},
],
}
}
}
)

// Register tool to search logs by Ray ID across all workers
agent.server.tool(
'worker_logs_by_rayid',
'Analyze recent logs across all workers for a specific request by Cloudflare Ray ID',
{
rayId: z.string().describe('Filter logs by specific Cloudflare Ray ID'),
shouldFilterErrors: filterErrorsParam,
limitParam,
minutesAgoParam,
},
async (params) => {
const accountId = agent.getActiveAccountId()
if (!accountId) {
return {
content: [
{
type: 'text',
text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
},
],
}
}
try {
const { rayId, shouldFilterErrors, limitParam, minutesAgoParam } = params
const { logs, from, to } = await handleWorkerLogs({
limit: limitParam,
minutesAgo: minutesAgoParam,
accountId,
apiToken: agent.props.accessToken,
shouldFilterErrors,
rayId,
})
return {
content: [
{
type: 'text',
text: JSON.stringify({
logs,
stats: {
timeRange: {
from,
to,
},
},
}),
},
],
}
} catch (error) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: `Error analyzing logs by Ray ID: ${error instanceof Error && error.message}`,
}),
},
],
}
}
}
)

// Register the worker telemetry keys tool
agent.server.tool(
'worker_logs_keys',
'Get available telemetry keys for a Cloudflare Worker',
{ scriptName: workerNameParam, minutesAgoParam },
async (params) => {
const accountId = agent.getActiveAccountId()
if (!accountId) {
return {
content: [
{
type: 'text',
text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
},
],
}
}
try {
const { scriptName, minutesAgoParam } = params
const keys = await handleWorkerLogsKeys(
scriptName,
minutesAgoParam,
accountId,
agent.props.accessToken
)

return {
content: [
{
type: 'text',
text: JSON.stringify({
keys: keys.map((key) => ({
key: key.key,
type: key.type,
lastSeenAt: key.lastSeenAt ? new Date(key.lastSeenAt).toISOString() : null,
})),
stats: {
total: keys.length,
byType: keys.reduce(
(acc, key) => {
acc[key.type] = (acc[key.type] || 0) + 1
return acc
},
{} as Record<string, number>
),
},
}),
},
],
}
} catch (error) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: `Error retrieving worker telemetry keys: ${error instanceof Error && error.message}`,
}),
},
],
}
}
}
)
}
3 changes: 3 additions & 0 deletions apps/template-start-here/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "@repo/typescript-config/workers.json"
}
5 changes: 5 additions & 0 deletions apps/template-start-here/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 {}
}
Loading
Loading