Skip to content

Commit a3d94bd

Browse files
committed
Init: DEX MCP Server
1 parent 2d76979 commit a3d94bd

File tree

14 files changed

+6305
-3
lines changed

14 files changed

+6305
-3
lines changed

apps/dex-analysis/.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/dex-analysis/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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`. It has a basic set of tools `apps/template-start-here/src/tools/logs.ts` — you can modify these to do what you need
6+
7+
## Getting Started
8+
9+
10+
- Set secrets via Wrangler (ask in the `Cloudflare's Own MCP Servers` internal channel to get credentials)
11+
12+
```bash
13+
wrangler secret put CLOUDFLARE_CLIENT_ID
14+
wrangler secret put CLOUDFLARE_CLIENT_SECRET
15+
```
16+
17+
#### Set up a KV namespace
18+
19+
- Create the KV namespace:
20+
`wrangler kv:namespace create "OAUTH_KV"`
21+
- Update the Wrangler file with the KV ID
22+
23+
#### Deploy & Test
24+
25+
Deploy the MCP server to make it available on your workers.dev domain
26+
` wrangler deploy`
27+
28+
Test the remote server using [Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
29+
30+
```
31+
npx @modelcontextprotocol/inspector@latest
32+
```
33+
34+
## Deploying to production
35+
36+
- You will need to liberate the zone (LTZ) for your `<server-name>.mcp.cloudflare.com`

apps/dex-analysis/package.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "dex-analysis",
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+
}

apps/dex-analysis/src/api/dex.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { fetchCloudflareApi } from '@repo/mcp-common/src/cloudflare-api'
2+
3+
export const fetchDexTestAnalyzation = async ({
4+
dexTestId,
5+
accountId,
6+
accessToken,
7+
timeStart,
8+
timeEnd,
9+
}: {
10+
dexTestId: string
11+
accountId: string
12+
accessToken: string
13+
timeStart: string
14+
timeEnd: string
15+
}) => {
16+
return await fetchCloudflareApi({
17+
endpoint: `/dex/test-results/by-quartile?from=${timeStart}&to=${timeEnd}&limit=5&testId=${dexTestId}`,
18+
accountId,
19+
apiToken: accessToken,
20+
options: {
21+
method: 'GET',
22+
headers: {
23+
'Content-Type': 'application/json',
24+
},
25+
},
26+
})
27+
}
28+
29+
export const fetchDexTests = async ({
30+
accountId,
31+
accessToken,
32+
}: {
33+
accountId: string
34+
accessToken: string
35+
}) => {
36+
return await fetchCloudflareApi({
37+
endpoint: '/dex/tests/overview?per_page=50',
38+
accountId,
39+
apiToken: accessToken,
40+
options: {
41+
method: 'GET',
42+
headers: {
43+
'Content-Type': 'application/json',
44+
},
45+
},
46+
})
47+
}

apps/dex-analysis/src/index.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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+
createAuthHandlers,
8+
handleTokenExchangeCallback,
9+
} from '@repo/mcp-common/src/cloudflare-oauth-handler'
10+
import { registerAccountTools } from '@repo/mcp-common/src/tools/account'
11+
12+
import { registerDEXTools } from './tools/dex'
13+
14+
import type { AccountSchema, UserSchema } from '@repo/mcp-common/src/cloudflare-oauth-handler'
15+
16+
// Context from the auth process, encrypted & stored in the auth token
17+
// and provided to the DurableMCP as this.props
18+
export type Props = {
19+
accessToken: string
20+
user: UserSchema['result']
21+
accounts: AccountSchema['result']
22+
}
23+
24+
export type State = { activeAccountId: string | null }
25+
26+
export class MyMCP extends McpAgent<Env, State, Props> {
27+
server = new McpServer({
28+
name: 'Remote MCP Server with Cloudflare DEX Analysis',
29+
version: '1.0.0',
30+
})
31+
32+
initialState: State = {
33+
activeAccountId: null,
34+
}
35+
36+
async init() {
37+
registerAccountTools(this)
38+
39+
// EXAMPLE TOOLS — register your own here
40+
registerDEXTools(this)
41+
}
42+
43+
getActiveAccountId() {
44+
// TODO: Figure out why this fail sometimes, and why we need to wrap this in a try catch
45+
try {
46+
return this.state.activeAccountId ?? null
47+
} catch (e) {
48+
return null
49+
}
50+
}
51+
52+
setActiveAccountId(accountId: string) {
53+
// TODO: Figure out why this fail sometimes, and why we need to wrap this in a try catch
54+
try {
55+
this.setState({
56+
...this.state,
57+
activeAccountId: accountId,
58+
})
59+
} catch (e) {
60+
return null
61+
}
62+
}
63+
}
64+
65+
const DexScopes = {
66+
'account:read': 'See your account info such as account details, analytics, and memberships.',
67+
'user:read': 'See your user info such as name, email address, and account memberships.',
68+
'teams:read': 'See Cloudflare Cloudflare Zero Trust data for your account', // TODO: Finalize configuration for this.
69+
offline_access: 'Grants refresh tokens for long-lived access.',
70+
} as const
71+
72+
export default new OAuthProvider({
73+
apiRoute: '/sse',
74+
// @ts-ignore
75+
apiHandler: MyMCP.mount('/sse'),
76+
// @ts-ignore
77+
defaultHandler: createAuthHandlers({ scopes: DexScopes }),
78+
authorizeEndpoint: '/oauth/authorize',
79+
tokenEndpoint: '/token',
80+
tokenExchangeCallback: (options) =>
81+
handleTokenExchangeCallback(options, env.CLOUDFLARE_CLIENT_ID, env.CLOUDFLARE_CLIENT_SECRET),
82+
// Cloudflare access token TTL
83+
accessTokenTTL: 3600,
84+
clientRegistrationEndpoint: '/register',
85+
})

apps/dex-analysis/src/tools/dex.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { z } from 'zod'
2+
3+
import { fetchDexTestAnalyzation, fetchDexTests } from '../api/dex'
4+
5+
import type { MyMCP } from '../index'
6+
7+
// Worker logs parameter schema
8+
const dexTestIdParam = z.string().describe('The DEX Test ID to analyze details of.')
9+
const dexTestTimeStart = z
10+
.string()
11+
.describe(
12+
'The datetime of the beginning point of time range for DEX test analyzation. Must be in ISO 8601 datetime string in the extended format with UTC time (e.g, 2025-04-21T18:00:00Z).'
13+
)
14+
const dexTestTimeEnd = z
15+
.string()
16+
.describe(
17+
'The datetime of the ending point of time range for DEX test analyzation. Must be in ISO 8601 datetime string in the extended format with UTC time (e.g, 2025-04-22T00:00:00Z).'
18+
)
19+
20+
/**
21+
* Registers the dex analysis tool with the MCP server
22+
* @param server The MCP server instance
23+
* @param accountId Cloudflare account ID
24+
* @param apiToken Cloudflare API token
25+
*/
26+
27+
export function registerDEXTools(agent: MyMCP) {
28+
// Register the dex test analysis tool by test id
29+
agent.server.tool(
30+
'dex_test_statistics',
31+
'Analyze Cloudflare DEX Test Results given a Test ID',
32+
{
33+
dexTestId: dexTestIdParam,
34+
timeStart: dexTestTimeStart,
35+
timeEnd: dexTestTimeEnd,
36+
},
37+
async (params) => {
38+
const accountId = agent.getActiveAccountId()
39+
if (!accountId) {
40+
return {
41+
content: [
42+
{
43+
type: 'text',
44+
text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
45+
},
46+
],
47+
}
48+
}
49+
try {
50+
const { dexTestId, timeStart, timeEnd } = params
51+
const accessToken = agent.props.accessToken
52+
const data = await fetchDexTestAnalyzation({
53+
dexTestId,
54+
accountId,
55+
accessToken,
56+
timeStart,
57+
timeEnd,
58+
})
59+
return {
60+
content: [
61+
{
62+
type: 'text',
63+
text: JSON.stringify({
64+
data,
65+
llmContext:
66+
"The quartiles are sorted by 'resource fetch time' from LEAST performant in quartile 1 to MOST performant in quartile 4. For each quartile-based entry, it provides extensive information about the up-to-20 specific test results that are within that quartile of performance.",
67+
}),
68+
},
69+
],
70+
}
71+
} catch (error) {
72+
return {
73+
content: [
74+
{
75+
type: 'text',
76+
text: JSON.stringify({
77+
error: `Error retreiving DEX HTTP Tests: ${error instanceof Error && error.message}`,
78+
}),
79+
},
80+
],
81+
}
82+
}
83+
}
84+
)
85+
86+
// Register the dex test analysis tool by test id
87+
agent.server.tool(
88+
'dex_list_tests',
89+
'Retrieve a list of all Cloudflare DEX Tests configured.',
90+
{},
91+
async () => {
92+
const accountId = agent.getActiveAccountId()
93+
if (!accountId) {
94+
return {
95+
content: [
96+
{
97+
type: 'text',
98+
text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
99+
},
100+
],
101+
}
102+
}
103+
try {
104+
const accessToken = agent.props.accessToken
105+
const data = await fetchDexTests({ accountId, accessToken })
106+
return {
107+
content: [
108+
{
109+
type: 'text',
110+
text: JSON.stringify({
111+
data,
112+
}),
113+
},
114+
],
115+
}
116+
} catch (error) {
117+
return {
118+
content: [
119+
{
120+
type: 'text',
121+
text: JSON.stringify({
122+
error: `Error retreiving DEX Tests: ${error instanceof Error && error.message}`,
123+
}),
124+
},
125+
],
126+
}
127+
}
128+
}
129+
)
130+
}

apps/dex-analysis/tsconfig.json

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+
}

apps/dex-analysis/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+
}

apps/dex-analysis/vitest.config.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'
2+
3+
export interface TestEnv extends Env {
4+
CLOUDFLARE_MOCK_ACCOUNT_ID: string
5+
CLOUDFLARE_MOCK_API_TOKEN: string
6+
}
7+
8+
export default defineWorkersConfig({
9+
test: {
10+
poolOptions: {
11+
workers: {
12+
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
13+
miniflare: {
14+
bindings: {
15+
CLOUDFLARE_MOCK_ACCOUNT_ID: 'mock-account-id',
16+
CLOUDFLARE_MOCK_API_TOKEN: 'mock-api-token',
17+
} satisfies Partial<TestEnv>,
18+
},
19+
},
20+
},
21+
},
22+
})

0 commit comments

Comments
 (0)