Skip to content

Commit 7d4d72b

Browse files
committed
Starter template for MCP servers
1 parent 5ba47c9 commit 7d4d72b

File tree

11 files changed

+6196
-0
lines changed

11 files changed

+6196
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CLOUDFLARE_CLIENT_ID=
2+
CLOUDFLARE_CLIENT_SECRET=
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/template-start-here/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Model Context Protocol (MCP) Server + Cloudflare OAuth
2+
3+
This is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that supports remote MCP connections, with Cloudflare OAuth built-in.
4+
5+
You should use this as a template to build an MCP server for Cloudflare, provided by Cloudflare at `server-name.mcp.cloudflare.com`.
6+
7+
## Getting Started
8+
9+
### For Production
10+
11+
- Set secrets via Wrangler
12+
13+
```bash
14+
wrangler secret put CLOUDFLARE_CLIENT_ID
15+
wrangler secret put CLOUDFLARE_CLIENT_SECRET
16+
```
17+
18+
#### Set up a KV namespace
19+
20+
- Create the KV namespace:
21+
`wrangler kv:namespace create "OAUTH_KV"`
22+
- Update the Wrangler file with the KV ID
23+
24+
#### Deploy & Test
25+
26+
Deploy the MCP server to make it available on your workers.dev domain
27+
` wrangler deploy`
28+
29+
Test the remote server using [Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
30+
31+
```
32+
npx @modelcontextprotocol/inspector@latest
33+
```
34+
35+
## Deploying to production
36+
37+
- You will need to liberate the zone (LTZ) for your `<server-name>.mcp.cloudflare.com`
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "workers-observability",
3+
"version": "0.0.1",
4+
"private": true,
5+
"scripts": {
6+
"check:lint": "run-eslint-workers",
7+
"check:types": "run-tsc",
8+
"deploy": "wrangler deploy",
9+
"dev": "wrangler dev",
10+
"start": "wrangler dev",
11+
"cf-typegen": "wrangler types",
12+
"test": "vitest run"
13+
},
14+
"dependencies": {
15+
"@cloudflare/workers-oauth-provider": "0.0.2",
16+
"@hono/zod-validator": "0.4.3",
17+
"@modelcontextprotocol/sdk": "1.8.0",
18+
"@repo/mcp-common": "workspace:*",
19+
"agents": "0.0.49",
20+
"cloudflare": "4.2.0",
21+
"hono": "4.7.6",
22+
"zod": "3.24.2"
23+
},
24+
"devDependencies": {
25+
"@cloudflare/vitest-pool-workers": "0.8.14",
26+
"@cloudflare/workers-types": "4.20250410.0",
27+
"@types/jsonwebtoken": "9.0.9",
28+
"prettier": "3.5.3",
29+
"typescript": "5.5.4",
30+
"vitest": "3.0.9",
31+
"wrangler": "4.10.0"
32+
}
33+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import OAuthProvider from '@cloudflare/workers-oauth-provider'
2+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3+
import { McpAgent } from 'agents/mcp'
4+
import { env } from 'cloudflare:workers'
5+
6+
import {
7+
CloudflareAuthHandler,
8+
handleTokenExchangeCallback,
9+
} from '@repo/mcp-common/src/cloudflare-oauth-handler'
10+
import { registerAccountTools } from '@repo/mcp-common/src/tools/account'
11+
12+
// EXAMPLE TOOLS — Edit this to create your own tools
13+
import { registerLogsTools } from './tools/logs'
14+
15+
import type { AccountSchema, UserSchema } from '@repo/mcp-common/src/cloudflare-oauth-handler'
16+
17+
// Context from the auth process, encrypted & stored in the auth token
18+
// and provided to the DurableMCP as this.props
19+
export type Props = {
20+
accessToken: string
21+
user: UserSchema['result']
22+
accounts: AccountSchema['result']
23+
}
24+
25+
export type State = { activeAccountId: string | null }
26+
27+
export class MyMCP extends McpAgent<Env, State, Props> {
28+
server = new McpServer({
29+
name: 'Remote MCP Server with Workers Observability',
30+
version: '1.0.0',
31+
})
32+
33+
initialState: State = {
34+
activeAccountId: null,
35+
}
36+
37+
async init() {
38+
registerAccountTools(this)
39+
40+
// EXAMPLE TOOLS — register your own here
41+
registerLogsTools(this)
42+
43+
}
44+
45+
getActiveAccountId() {
46+
// TODO: Figure out why this fail sometimes, and why we need to wrap this in a try catch
47+
try {
48+
return this.state.activeAccountId ?? null
49+
} catch (e) {
50+
return null
51+
}
52+
}
53+
54+
setActiveAccountId(accountId: string) {
55+
// TODO: Figure out why this fail sometimes, and why we need to wrap this in a try catch
56+
try {
57+
this.setState({
58+
...this.state,
59+
activeAccountId: accountId,
60+
})
61+
} catch (e) {
62+
return null
63+
}
64+
}
65+
}
66+
67+
export default new OAuthProvider({
68+
apiRoute: '/workers/observability/sse',
69+
// @ts-ignore
70+
apiHandler: MyMCP.mount('/workers/observability/sse'),
71+
// @ts-ignore
72+
defaultHandler: CloudflareAuthHandler,
73+
authorizeEndpoint: '/oauth/authorize',
74+
tokenEndpoint: '/token',
75+
tokenExchangeCallback: (options) =>
76+
handleTokenExchangeCallback(options, env.CLOUDFLARE_CLIENT_ID, env.CLOUDFLARE_CLIENT_SECRET),
77+
// Cloudflare access token TTL
78+
accessTokenTTL: 3600,
79+
clientRegistrationEndpoint: '/register',
80+
})
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// EXAMPLE TOOLS — Edit this to create your own tools
2+
3+
import { z } from 'zod'
4+
5+
import { handleWorkerLogs, handleWorkerLogsKeys } from '@repo/mcp-common/src/api/workers-logs'
6+
7+
import type { MyMCP } from '../index'
8+
9+
// Worker logs parameter schema
10+
const workerNameParam = z.string().describe('The name of the worker to analyze logs for')
11+
const filterErrorsParam = z.boolean().default(false).describe('If true, only shows error logs')
12+
const limitParam = z
13+
.number()
14+
.min(1)
15+
.max(100)
16+
.default(100)
17+
.describe('Maximum number of logs to retrieve (1-100, default 100)')
18+
const minutesAgoParam = z
19+
.number()
20+
.min(1)
21+
.max(10080)
22+
.default(30)
23+
.describe('Minutes in the past to look for logs (1-10080, default 30)')
24+
const rayIdParam = z.string().optional().describe('Filter logs by specific Cloudflare Ray ID')
25+
26+
/**
27+
* Registers the logs analysis tool with the MCP server
28+
* @param server The MCP server instance
29+
* @param accountId Cloudflare account ID
30+
* @param apiToken Cloudflare API token
31+
*/
32+
export function registerLogsTools(agent: MyMCP) {
33+
// Register the worker logs analysis tool by worker name
34+
agent.server.tool(
35+
'worker_logs_by_worker_name',
36+
'Analyze recent logs for a Cloudflare Worker by worker name',
37+
{
38+
scriptName: workerNameParam,
39+
shouldFilterErrors: filterErrorsParam,
40+
limitParam,
41+
minutesAgoParam,
42+
rayId: rayIdParam,
43+
},
44+
async (params) => {
45+
const accountId = agent.getActiveAccountId()
46+
if (!accountId) {
47+
return {
48+
content: [
49+
{
50+
type: 'text',
51+
text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
52+
},
53+
],
54+
}
55+
}
56+
try {
57+
const { scriptName, shouldFilterErrors, limitParam, minutesAgoParam, rayId } = params
58+
const { logs, from, to } = await handleWorkerLogs({
59+
scriptName,
60+
limit: limitParam,
61+
minutesAgo: minutesAgoParam,
62+
accountId,
63+
apiToken: agent.props.accessToken,
64+
shouldFilterErrors,
65+
rayId,
66+
})
67+
return {
68+
content: [
69+
{
70+
type: 'text',
71+
text: JSON.stringify({
72+
logs,
73+
stats: {
74+
timeRange: {
75+
from,
76+
to,
77+
},
78+
},
79+
}),
80+
},
81+
],
82+
}
83+
} catch (error) {
84+
return {
85+
content: [
86+
{
87+
type: 'text',
88+
text: JSON.stringify({
89+
error: `Error analyzing worker logs: ${error instanceof Error && error.message}`,
90+
}),
91+
},
92+
],
93+
}
94+
}
95+
}
96+
)
97+
98+
// Register tool to search logs by Ray ID across all workers
99+
agent.server.tool(
100+
'worker_logs_by_rayid',
101+
'Analyze recent logs across all workers for a specific request by Cloudflare Ray ID',
102+
{
103+
rayId: z.string().describe('Filter logs by specific Cloudflare Ray ID'),
104+
shouldFilterErrors: filterErrorsParam,
105+
limitParam,
106+
minutesAgoParam,
107+
},
108+
async (params) => {
109+
const accountId = agent.getActiveAccountId()
110+
if (!accountId) {
111+
return {
112+
content: [
113+
{
114+
type: 'text',
115+
text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
116+
},
117+
],
118+
}
119+
}
120+
try {
121+
const { rayId, shouldFilterErrors, limitParam, minutesAgoParam } = params
122+
const { logs, from, to } = await handleWorkerLogs({
123+
limit: limitParam,
124+
minutesAgo: minutesAgoParam,
125+
accountId,
126+
apiToken: agent.props.accessToken,
127+
shouldFilterErrors,
128+
rayId,
129+
})
130+
return {
131+
content: [
132+
{
133+
type: 'text',
134+
text: JSON.stringify({
135+
logs,
136+
stats: {
137+
timeRange: {
138+
from,
139+
to,
140+
},
141+
},
142+
}),
143+
},
144+
],
145+
}
146+
} catch (error) {
147+
return {
148+
content: [
149+
{
150+
type: 'text',
151+
text: JSON.stringify({
152+
error: `Error analyzing logs by Ray ID: ${error instanceof Error && error.message}`,
153+
}),
154+
},
155+
],
156+
}
157+
}
158+
}
159+
)
160+
161+
// Register the worker telemetry keys tool
162+
agent.server.tool(
163+
'worker_logs_keys',
164+
'Get available telemetry keys for a Cloudflare Worker',
165+
{ scriptName: workerNameParam, minutesAgoParam },
166+
async (params) => {
167+
const accountId = agent.getActiveAccountId()
168+
if (!accountId) {
169+
return {
170+
content: [
171+
{
172+
type: 'text',
173+
text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
174+
},
175+
],
176+
}
177+
}
178+
try {
179+
const { scriptName, minutesAgoParam } = params
180+
const keys = await handleWorkerLogsKeys(
181+
scriptName,
182+
minutesAgoParam,
183+
accountId,
184+
agent.props.accessToken
185+
)
186+
187+
return {
188+
content: [
189+
{
190+
type: 'text',
191+
text: JSON.stringify({
192+
keys: keys.map((key) => ({
193+
key: key.key,
194+
type: key.type,
195+
lastSeenAt: key.lastSeenAt ? new Date(key.lastSeenAt).toISOString() : null,
196+
})),
197+
stats: {
198+
total: keys.length,
199+
byType: keys.reduce(
200+
(acc, key) => {
201+
acc[key.type] = (acc[key.type] || 0) + 1
202+
return acc
203+
},
204+
{} as Record<string, number>
205+
),
206+
},
207+
}),
208+
},
209+
],
210+
}
211+
} catch (error) {
212+
return {
213+
content: [
214+
{
215+
type: 'text',
216+
text: JSON.stringify({
217+
error: `Error retrieving worker telemetry keys: ${error instanceof Error && error.message}`,
218+
}),
219+
},
220+
],
221+
}
222+
}
223+
}
224+
)
225+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "@repo/typescript-config/workers.json"
3+
}
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)