Skip to content

Commit a542920

Browse files
committed
dex-analysis: add WARP diag analysis tools and reader D.O.
1 parent ce7ad70 commit a542920

File tree

8 files changed

+254
-27
lines changed

8 files changed

+254
-27
lines changed

apps/dex-analysis/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Currently available tools:
2020
| **Remote Captures** | `dex_list_remote_capture_eligible_devices` | Retrieve a list of devices eligible for remote captures like packet captures or WARP diagnostics. |
2121
| | `dex_create_remote_capture` | Initiate a remote capture on a specific device by id. |
2222
| | `dex_list_remote_captures` | Retrieve a list of previously created remote captures along with their details and status. |
23+
| | `dex_list_remote_warp_diag_contents` | List the filenames included in a remote WARP diag capture returned by `dex_list_remote_captures`. |
24+
| | `dex_explore_remote_warp_diag_output` | Retreive remote WARP diag file contents by filepath returned by `dex_list_remote_warp_diag_contents`. |
2325
| **Fleet Status** | `dex_fleet_status_live` | View live metrics for your fleet of zero trust devices for up to the past 1 hour. |
2426
| | `dex_fleet_status_over_time` | View historical metrics for your fleet of zero trust devices over time. |
2527
| | `dex_fleet_status_logs` | View historical logs for your fleet of zero trust devices for up to the past 7 days. |
@@ -37,6 +39,7 @@ This MCP server is still a work in progress, and we plan to add more tools in th
3739
- `Capture a WARP diag for [email protected] and make sure to test all routes`
3840
- `Which users have toggled off WARP recently?`
3941
- `Which Cloudflare colo is most used by my users in the EU running DEX application tests?`
42+
- `Look at the latest WARP diag for [email protected] and tell me if you see anything notable in dns logs`
4043

4144
## Access the remote MCP server from any MCP Client
4245

apps/dex-analysis/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"agents": "0.0.100",
2222
"cloudflare": "4.2.0",
2323
"hono": "4.7.6",
24+
"jszip": "3.10.1",
2425
"zod": "3.24.2"
2526
},
2627
"devDependencies": {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler'
1919
import type { Env } from './dex-analysis.context'
2020

2121
export { UserDetails }
22+
export { WarpDiagReader } from './warp_diag_reader'
2223

2324
const env = getEnv<Env>()
2425

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do'
22
import type { CloudflareDEXMCP } from './dex-analysis.app'
3+
import type { WarpDiagReader } from './warp_diag_reader'
34

45
export interface Env {
56
OAUTH_KV: KVNamespace
@@ -10,6 +11,7 @@ export interface Env {
1011
CLOUDFLARE_CLIENT_SECRET: string
1112
MCP_OBJECT: DurableObjectNamespace<CloudflareDEXMCP>
1213
USER_DETAILS: DurableObjectNamespace<UserDetails>
14+
WARP_DIAG_READER: DurableObjectNamespace<WarpDiagReader>
1315
MCP_METRICS: AnalyticsEngineDataset
1416
DEV_DISABLE_OAUTH: string
1517
DEV_CLOUDFLARE_API_TOKEN: string

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { z } from 'zod'
22

33
import { fetchCloudflareApi } from '@repo/mcp-common/src/cloudflare-api'
4+
import { getEnv } from '@repo/mcp-common/src/env'
5+
6+
import { getReader } from '../warp_diag_reader'
47

58
import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'
69
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
710
import type { ZodRawShape, ZodTypeAny } from 'zod'
811
import type { CloudflareDEXMCP } from '../dex-analysis.app'
12+
import type { Env } from '../dex-analysis.context'
13+
14+
const env = getEnv<Env>()
915

1016
export function registerDEXTools(agent: CloudflareDEXMCP) {
1117
registerTool({
@@ -478,6 +484,48 @@ export function registerDEXTools(agent: CloudflareDEXMCP) {
478484
})
479485
},
480486
})
487+
488+
registerTool({
489+
name: 'dex_list_remote_warp_diag_contents',
490+
description:
491+
'Given a WARP diag remote capture download url, returns a list of the files contained in the archive.',
492+
schema: {
493+
download: z
494+
.string()
495+
.describe(
496+
'The `filename` url from the dex_list_remote_captures response for successful WARP diag captures.'
497+
),
498+
},
499+
llmContext:
500+
'Use the dex_explore_remote_warp_diag_output tool for specific file paths to explore the file contents for analysis. ' +
501+
'Hint: you can call dex_explore_remote_warp_diag_output multiple times in parallel if necessary to take advantage of in-memory caching for best performance.',
502+
agent,
503+
callback: async ({ accessToken, download }) => {
504+
const reader = await getReader(env, accessToken, download)
505+
return await reader.list(accessToken, download)
506+
},
507+
})
508+
509+
registerTool({
510+
name: 'dex_explore_remote_warp_diag_output',
511+
description:
512+
'Explore the contents of remote capture WARP diag archive filepaths returned by the dex_list_remote_warp_diag_contents tool for analysis.',
513+
schema: {
514+
download: z
515+
.string()
516+
.describe(
517+
'The `filename` url from the dex_list_remote_captures response for successful WARP diag captures.'
518+
),
519+
filepath: z.string().describe('The file path from the archive to retrieve contents for.'),
520+
},
521+
llmContext:
522+
'To avoid hitting conversation and memory limits, avoid outputting the whole contents of these files to the user unless specifically asked to. Instead prefer to show relevant snippets only.',
523+
agent,
524+
callback: async ({ accessToken, download, filepath }) => {
525+
const reader = await getReader(env, accessToken, download)
526+
return await reader.read(accessToken, download, filepath)
527+
},
528+
})
481529
}
482530

483531
// Helper to simplify tool registration by reducing boilerplate for accountId and accessToken
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { DurableObject } from 'cloudflare:workers'
2+
import JSZip from 'jszip'
3+
4+
import type { Env } from './dex-analysis.context'
5+
6+
// Helper for reading large WARP diag zip archives.
7+
// Holds the contents in memory between requests from the agent for specific files
8+
// instead of having the worker download the zip on every request.
9+
//
10+
// Each DO represents one remote capture zip
11+
export class WarpDiagReader extends DurableObject<Env> {
12+
#cache?: { files: string[]; zip: JSZip }
13+
14+
// List the files in the zip for the agent
15+
async list(accessToken: string, url: string) {
16+
const { files } = await this.#getZip(accessToken, url)
17+
return files
18+
}
19+
20+
// Return the contents of a file by path
21+
async read(accessToken: string, url: string, filepath: string) {
22+
const { zip } = await this.#getZip(accessToken, url)
23+
const file = zip.file(filepath)
24+
const content = await file?.async('text')
25+
return content
26+
}
27+
28+
async #getZip(accessToken: string, url: string) {
29+
if (this.#cache) {
30+
return this.#cache
31+
}
32+
33+
console.log(`WarpDiagReader fetching `, url)
34+
35+
const res = await fetch(url, {
36+
headers: {
37+
Authorization: `Bearer ${accessToken}`,
38+
},
39+
})
40+
41+
if (res.status !== 200) {
42+
throw new Error(`failed to download zip, non-200 status code: ${res.status}`)
43+
}
44+
45+
const zip = await new JSZip().loadAsync(await res.arrayBuffer())
46+
const files: string[] = []
47+
for (const [relativePath, file] of Object.entries(zip.files)) {
48+
if (!file.dir) {
49+
files.push(relativePath)
50+
}
51+
}
52+
53+
const cache = { files, zip }
54+
this.#cache = cache
55+
return cache
56+
}
57+
}
58+
59+
async function hashToken(accessToken: string) {
60+
const hashArr = Array.from(
61+
new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken)))
62+
)
63+
return hashArr.map((b) => b.toString(16).padStart(2, '0')).join('')
64+
}
65+
66+
// Create unique name based on accessToken hash and download url. In order to read cached zip from memory
67+
// you need to have the same access token that was used to fetch it.
68+
async function readerName(accessToken: string, url: string) {
69+
return (await hashToken(accessToken)) + url
70+
}
71+
72+
export async function getReader(env: Env, accessToken: string, download: string) {
73+
const name = await readerName(accessToken, download)
74+
const id = env.WARP_DIAG_READER.idFromName(name)
75+
return env.WARP_DIAG_READER.get(id)
76+
}

apps/dex-analysis/wrangler.jsonc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
{
1212
"new_sqlite_classes": ["CloudflareDEXMCP"],
1313
"tag": "v1"
14+
},
15+
{
16+
"new_sqlite_classes": ["WarpDiagReader"],
17+
"tag": "v2"
1418
}
1519
],
1620
"observability": {
@@ -25,6 +29,10 @@
2529
{
2630
"class_name": "UserDetails",
2731
"name": "USER_DETAILS"
32+
},
33+
{
34+
"class_name": "WarpDiagReader",
35+
"name": "WARP_DIAG_READER"
2836
}
2937
]
3038
},
@@ -65,6 +73,10 @@
6573
"class_name": "UserDetails",
6674
"name": "USER_DETAILS",
6775
"script_name": "mcp-cloudflare-workers-observability-staging"
76+
},
77+
{
78+
"class_name": "WarpDiagReader",
79+
"name": "WARP_DIAG_READER"
6880
}
6981
]
7082
},
@@ -98,6 +110,10 @@
98110
"class_name": "UserDetails",
99111
"name": "USER_DETAILS",
100112
"script_name": "mcp-cloudflare-workers-observability-production"
113+
},
114+
{
115+
"class_name": "WarpDiagReader",
116+
"name": "WARP_DIAG_READER"
101117
}
102118
]
103119
},

0 commit comments

Comments
 (0)