Skip to content

Commit 836dd35

Browse files
committed
feat: Cloudflare One Email Security MCP Server
1 parent a20708d commit 836dd35

File tree

11 files changed

+9701
-4894
lines changed

11 files changed

+9701
-4894
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CLOUDFLARE_CLIENT_ID=
2+
CLOUDFLARE_CLIENT_SECRET=
3+
DEV_DISABLE_OAUTH=
4+
DEV_CLOUDFLARE_API_TOKEN=
5+
DEV_CLOUDFLARE_EMAIL=
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "cloudflare-email-security-mcp-server",
3+
"version": "0.0.1",
4+
"private": true,
5+
"scripts": {
6+
"check:lint": "run-eslint-workers",
7+
"check:types": "run-tsc",
8+
"deploy": "run-wrangler-deploy",
9+
"dev": "wrangler dev",
10+
"start": "wrangler dev",
11+
"types": "wrangler types --include-env=false",
12+
"test": "vitest run"
13+
},
14+
"dependencies": {
15+
"@cloudflare/workers-oauth-provider": "0.0.5",
16+
"@hono/zod-validator": "0.4.3",
17+
"@modelcontextprotocol/sdk": "1.10.2",
18+
"@repo/mcp-common": "workspace:*",
19+
"@repo/mcp-observability": "workspace:*",
20+
"agents": "0.0.67",
21+
"cloudflare": "4.2.0",
22+
"hono": "4.7.6",
23+
"zod": "3.24.2"
24+
},
25+
"devDependencies": {
26+
"@cloudflare/vitest-pool-workers": "0.8.14",
27+
"@types/jsonwebtoken": "9.0.9",
28+
"@types/node": "22.14.1",
29+
"prettier": "3.5.3",
30+
"typescript": "5.5.4",
31+
"vitest": "3.0.9",
32+
"wrangler": "4.10.0"
33+
}
34+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import OAuthProvider from '@cloudflare/workers-oauth-provider'
2+
import { McpAgent } from 'agents/mcp'
3+
4+
import {
5+
createAuthHandlers,
6+
handleTokenExchangeCallback,
7+
} from '@repo/mcp-common/src/cloudflare-oauth-handler'
8+
import { handleDevMode } from '@repo/mcp-common/src/dev-mode'
9+
import { getUserDetails, UserDetails } from '@repo/mcp-common/src/durable-objects/user_details.do'
10+
import { getEnv } from '@repo/mcp-common/src/env'
11+
import { RequiredScopes } from '@repo/mcp-common/src/scopes'
12+
import { CloudflareMCPServer } from '@repo/mcp-common/src/server'
13+
import { registerAccountTools } from '@repo/mcp-common/src/tools/account.tools'
14+
import { MetricsTracker } from '@repo/mcp-observability'
15+
16+
import { registerEmailSecurityTools } from './tools/email-security.tools'
17+
18+
import type { AuthProps } from '@repo/mcp-common/src/cloudflare-oauth-handler'
19+
import type { Env } from './email-security.context.ts'
20+
21+
export { UserDetails }
22+
23+
const env = getEnv<Env>()
24+
25+
const metrics = new MetricsTracker(env.MCP_METRICS, {
26+
name: env.MCP_SERVER_NAME,
27+
version: env.MCP_SERVER_VERSION,
28+
})
29+
30+
// Context from the auth process, encrypted & stored in the auth token
31+
// and provided to the DurableMCP as this.props
32+
type Props = AuthProps
33+
34+
type State = { activeAccountId: string | null }
35+
36+
export class EmailSecurityMCP extends McpAgent<Env, State, Props> {
37+
_server: CloudflareMCPServer | undefined
38+
set server(server: CloudflareMCPServer) {
39+
this._server = server
40+
}
41+
42+
get server(): CloudflareMCPServer {
43+
if (!this._server) {
44+
throw new Error('Tried to access server before it was initialized')
45+
}
46+
47+
return this._server
48+
}
49+
50+
constructor(ctx: DurableObjectState, env: Env) {
51+
super(ctx, env)
52+
}
53+
54+
async init() {
55+
this.server = new CloudflareMCPServer({
56+
userId: this.props.user.id,
57+
wae: this.env.MCP_METRICS,
58+
serverInfo: {
59+
name: this.env.MCP_SERVER_NAME,
60+
version: this.env.MCP_SERVER_VERSION,
61+
},
62+
})
63+
64+
registerAccountTools(this)
65+
registerEmailSecurityTools(this)
66+
}
67+
68+
async getActiveAccountId() {
69+
try {
70+
// Get UserDetails Durable Object based off the userId and retrieve the activeAccountId from it
71+
// we do this so we can persist activeAccountId across sessions
72+
const userDetails = getUserDetails(env, this.props.user.id)
73+
return await userDetails.getActiveAccountId()
74+
} catch (e) {
75+
this.server.recordError(e)
76+
return null
77+
}
78+
}
79+
80+
async setActiveAccountId(accountId: string) {
81+
try {
82+
const userDetails = getUserDetails(env, this.props.user.id)
83+
await userDetails.setActiveAccountId(accountId)
84+
} catch (e) {
85+
this.server.recordError(e)
86+
}
87+
}
88+
}
89+
90+
const EmailSecurityScopes = {
91+
...RequiredScopes,
92+
'account:read':
93+
'See your account info such as account details, analytics, and memberships.',
94+
'email_security:read': 'See Cloud Email Security data for your account',
95+
} as const
96+
97+
export default {
98+
fetch: async (req: Request, env: Env, ctx: ExecutionContext) => {
99+
if (env.ENVIRONMENT === 'development' && env.DEV_DISABLE_OAUTH === 'true') {
100+
return await handleDevMode(EmailSecurityMCP, req, env, ctx)
101+
}
102+
103+
return new OAuthProvider({
104+
apiHandlers: {
105+
'/mcp': EmailSecurityMCP.serve('/mcp'),
106+
'/sse': EmailSecurityMCP.serveSSE('/sse'),
107+
},
108+
// @ts-ignore
109+
defaultHandler: createAuthHandlers({ scopes: EmailSecurityScopes, metrics }),
110+
authorizeEndpoint: '/oauth/authorize',
111+
tokenEndpoint: '/token',
112+
tokenExchangeCallback: (options) =>
113+
handleTokenExchangeCallback(
114+
options,
115+
env.CLOUDFLARE_CLIENT_ID,
116+
env.CLOUDFLARE_CLIENT_SECRET
117+
),
118+
// Cloudflare access token TTL
119+
accessTokenTTL: 3600,
120+
clientRegistrationEndpoint: '/register',
121+
}).fetch(req, env, ctx)
122+
},
123+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { EmailSecurityMCP, UserDetails } from './email-security.app'
2+
3+
export interface Env {
4+
ENVIRONMENT: 'development' | 'staging' | 'production'
5+
MCP_SERVER_NAME: string
6+
MCP_SERVER_VERSION: string
7+
MCP_OBJECT: DurableObjectNamespace<EmailSecurityMCP>
8+
MCP_METRICS: AnalyticsEngineDataset
9+
AI: Ai
10+
CLOUDFLARE_CLIENT_ID: string
11+
CLOUDFLARE_CLIENT_SECRET: string
12+
USER_DETAILS: DurableObjectNamespace<UserDetails>
13+
DEV_DISABLE_OAUTH: string
14+
DEV_CLOUDFLARE_API_TOKEN: string
15+
DEV_CLOUDFLARE_EMAIL: string
16+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { z } from 'zod'
2+
3+
import { withAccountCheck } from '@repo/mcp-common/src/api/account.api'
4+
import { getCloudflareClient } from '@repo/mcp-common/src/cloudflare-api'
5+
6+
import type { EmailSecurityMCP } from '../email-security.app'
7+
8+
const PAGE_SIZE = 20
9+
10+
// Define tool parameters
11+
const searchQueryParam = z
12+
.string()
13+
.optional()
14+
.describe(
15+
"Space-delimited search terms used to filter email messages. Matches various metadata fields such as subject, sender, hashes, etc. If omitted, all messages are returned."
16+
)
17+
18+
const startParam = z
19+
.string()
20+
.optional()
21+
.describe('ISO 8601 datetime string marking beginning of search range. Defaults to 30 days ago if omitted.')
22+
23+
const endParam = z
24+
.string()
25+
.optional()
26+
.describe('ISO 8601 datetime string marking end of search range. Defaults to now if omitted.')
27+
28+
export function registerEmailSecurityTools(agent: EmailSecurityMCP) {
29+
agent.server.tool(
30+
'email_security_search_messages',
31+
'Search Cloudflare Email Security messages via Investigate list endpoint',
32+
{ query: searchQueryParam, start: startParam, end: endParam },
33+
withAccountCheck<{
34+
query?: string
35+
start?: string
36+
end?: string
37+
}>(
38+
agent,
39+
async ({ query, start, end, accountId, apiToken }: {
40+
query?: string
41+
start?: string
42+
end?: string
43+
accountId: string | null
44+
apiToken: string
45+
}) => {
46+
const client = getCloudflareClient(apiToken)
47+
48+
const params: any = { account_id: accountId }
49+
if (query) params.query = query
50+
if (start) params.start = start
51+
if (end) params.end = end
52+
// Limit page result count per_page to PAGE_SIZE
53+
params.per_page = PAGE_SIZE
54+
55+
const messages: any[] = []
56+
try {
57+
for await (const item of client.emailSecurity.investigate.list(params)) {
58+
messages.push(item)
59+
}
60+
} catch (error) {
61+
return {
62+
error:
63+
error instanceof Error
64+
? error.message
65+
: 'Unknown error occurred while fetching messages',
66+
}
67+
}
68+
69+
return {
70+
messagesCount: messages.length,
71+
messages,
72+
}
73+
}
74+
)
75+
)
76+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "@repo/typescript-config/workers.json",
3+
"include": ["*/**.ts", "./types.d.ts", "./vitest.config.ts"]
4+
}
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+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'
2+
3+
import type { Env } from './src/email-security.context'
4+
5+
export interface TestEnv extends Env {
6+
CLOUDFLARE_MOCK_ACCOUNT_ID: string
7+
CLOUDFLARE_MOCK_API_TOKEN: string
8+
}
9+
10+
export default defineWorkersConfig({
11+
test: {
12+
poolOptions: {
13+
workers: {
14+
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
15+
miniflare: {
16+
bindings: {
17+
CLOUDFLARE_MOCK_ACCOUNT_ID: 'mock-account-id',
18+
CLOUDFLARE_MOCK_API_TOKEN: 'mock-api-token',
19+
} satisfies Partial<TestEnv>,
20+
},
21+
},
22+
},
23+
},
24+
})

0 commit comments

Comments
 (0)