Skip to content

Starter template for MCP servers #53

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
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'],
}
36 changes: 36 additions & 0 deletions apps/template-start-here/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 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`. It has a basic set of tools `apps/template-start-here/src/tools/logs.ts` — you can modify these to do what you need

## Getting Started


- Set secrets via Wrangler (ask in the `Cloudflare's Own MCP Servers` internal 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": "example-cloudflare-mcp-server",
"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