Skip to content

Commit ebcbad1

Browse files
authored
[Agents 0.20.1 + MCP SDK v2] Add Developer Stack MCP server (apps/stack-mcp) (#423)
* Add Developer Stack MCP server (apps/stack-mcp) * fix linting issue * Format stack-mcp with Prettier. Import ordering and line wrapping to satisfy check:format. * update read-me with scoping cases * Add /libraries public JSON endpoint for UI pickers Returns { libraries: [...] } with permissive CORS so browser UIs (e.g. the Workers AI Playground) can fetch the catalog cross-origin. * Migrate stack-mcp to Agents 0.20.1 + MCP SDK v2 Rebased onto production, which moved the framework to the stateless SDK v2 model (#420). Bring stack-mcp in line with the migrated sibling apps: - Bootstrap via createPublicMcpApp instead of a hand-written McpAgent DO; the server is now stateless (no Durable Object). - Register tools through the per-request McpRegistrationContext; read ?libs= from context.request so both /mcp and /sse honor scoping. - Schemas use z.object; drop the direct @modelcontextprotocol/sdk, agents, and @repo/mcp-observability deps (now provided via @repo/mcp-common). - Preserve the /libraries catalog endpoint by wrapping app.worker. - Add a stateless transport-contract spec; keep reranking always on. * small comment update
1 parent 77bc8a0 commit ebcbad1

13 files changed

Lines changed: 634 additions & 1 deletion

‎apps/stack-mcp/.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/stack-mcp/README.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Cloudflare Developer Stack MCP Server 🧰
2+
3+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that gives coding agents fresh, cited documentation for a **curated Cloudflare developer stack**. It's backed by Cloudflare AI Search and queried through the `ai_search_namespaces` binding.
4+
5+
## 🔨 Tools
6+
7+
The server exposes two read-only tools, one to discover what's available and one to search it:
8+
9+
| Tool | Description |
10+
| ---------------- | ---------------------------------------------------------------------------------------------- |
11+
| `list_libraries` | List the documentation libraries this server can search (slug, name, source, description). |
12+
| `search_docs` | Search across the stack (or one `library`), returning cited doc chunks with their source URLs. |
13+
14+
## 📚 Libraries
15+
16+
The stack spans Cloudflare's own docs, API, blog, and community, plus popular tools used alongside them. Each library maps to a `?libs=` slug you can use to scope the server (see below):
17+
18+
| `?libs=` slug | Library | Source |
19+
| ---------------------- | ------------------------- | ----------------------------- |
20+
| `cloudflare` | Cloudflare Developer Docs | developers.cloudflare.com |
21+
| `cloudflare-api` | Cloudflare API | developers.cloudflare.com/api |
22+
| `cloudflare-blog` | Cloudflare Blog | blog.cloudflare.com |
23+
| `cloudflare-community` | Cloudflare Community | community.cloudflare.com |
24+
| `vite` | Vite | vite.dev |
25+
| `vitest` | Vitest | vitest.dev |
26+
| `astro` | Astro | docs.astro.build |
27+
| `opennext` | OpenNext | opennext.js.org |
28+
| `replicate` | Replicate | replicate.com |
29+
| `hono` | Hono | hono.dev |
30+
31+
## 🔎 Scoping with `?libs=`
32+
33+
The `/mcp` endpoint accepts a `libs` query param to scope the server to a subset of the stack (using the slugs above), e.g.:
34+
35+
```
36+
https://stack.mcp.cloudflare.com/mcp?libs=cloudflare,hono,vite
37+
```
38+
39+
When scoped, `list_libraries` and `search_docs` (including its `library` enum) only expose the selected libraries, and cross-stack search is limited to them. Unknown slugs are ignored, and an empty or all-invalid `libs` falls back to the whole stack.
40+
41+
Scoping is read from the connection URL on every request, so it applies whether the client connects to `/mcp` or the `/sse` alias.
42+
43+
## Access the remote MCP server
44+
45+
Point any MCP client that supports remote servers at `https://stack.mcp.cloudflare.com/mcp` (streamable HTTP; the `/sse` path is served by the same stateless transport for backward compatibility). For clients without native remote support, use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote).
46+
47+
```json
48+
{
49+
"mcpServers": {
50+
"cloudflare-stack": {
51+
"command": "npx",
52+
"args": ["mcp-remote", "https://stack.mcp.cloudflare.com/mcp"]
53+
}
54+
}
55+
}
56+
```

‎apps/stack-mcp/package.json‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "stack-mcp",
3+
"version": "0.1.0",
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": "npm run dev",
11+
"types": "wrangler types --include-runtime false",
12+
"test": "vitest run"
13+
},
14+
"dependencies": {
15+
"@repo/mcp-common": "workspace:*",
16+
"zod": "4.4.3"
17+
},
18+
"devDependencies": {
19+
"@cloudflare/vitest-pool-workers": "0.16.11",
20+
"@types/node": "22.15.17",
21+
"prettier": "3.5.3",
22+
"typescript": "5.5.4",
23+
"vitest": "4.1.8",
24+
"wrangler": "4.96.0"
25+
},
26+
"type": "module"
27+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { createPublicMcpApp } from '@repo/mcp-common/src/mcp-app'
2+
3+
import { registerStackTools } from './tools/stack.tools'
4+
import { STACK_LIBRARIES, toPublicLibrary } from './types/stack.types'
5+
6+
import type { Env } from './stack-mcp.context'
7+
8+
const app = createPublicMcpApp<Env>({
9+
serviceHostnames: ['stack-staging.mcp.cloudflare.com', 'stack.mcp.cloudflare.com'],
10+
register: registerStackTools,
11+
})
12+
13+
export const mcpHandler = app.mcpHandler
14+
15+
export default {
16+
fetch(req: Request, env: Env, ctx: ExecutionContext) {
17+
const url = new URL(req.url)
18+
if (url.pathname === '/libraries') {
19+
// Public catalog for UI pickers (e.g. the Workers AI Playground). Fetched
20+
// cross-origin from the browser, so it needs permissive CORS.
21+
return Response.json(
22+
{ libraries: STACK_LIBRARIES.map(toPublicLibrary) },
23+
{ headers: { 'access-control-allow-origin': '*' } }
24+
)
25+
}
26+
return app.worker.fetch(req, env, ctx)
27+
},
28+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Minimal typings for the AI Search namespace binding (`ai_search_namespaces`).
3+
* Hand-written so this typechecks regardless of the wrangler version's
4+
* runtime types for this (beta) binding.
5+
*/
6+
export interface AiSearchChunk {
7+
text: string
8+
score: number
9+
item: { key: string; metadata?: Record<string, unknown> }
10+
/** Present on namespace-level (multi-instance) search: which instance the chunk came from. */
11+
instance_id?: string
12+
}
13+
14+
export interface AiSearchResult {
15+
search_query?: string
16+
chunks: AiSearchChunk[]
17+
errors?: Array<{ instance_id: string; message: string }>
18+
}
19+
20+
export interface AiSearchRetrievalOptions {
21+
max_num_results?: number
22+
match_threshold?: number
23+
retrieval_type?: 'vector' | 'keyword' | 'hybrid'
24+
}
25+
26+
export interface AiSearchRerankingOptions {
27+
enabled?: boolean
28+
model?: string
29+
match_threshold?: number
30+
}
31+
32+
export interface AiSearchInstance {
33+
search(params: {
34+
query: string
35+
ai_search_options?: {
36+
retrieval?: AiSearchRetrievalOptions
37+
reranking?: AiSearchRerankingOptions
38+
}
39+
}): Promise<AiSearchResult>
40+
}
41+
42+
export interface AiSearchNamespace {
43+
/** Get a single instance handle within the bound namespace. */
44+
get(name: string): AiSearchInstance
45+
/** Search across multiple instances in the bound namespace (requires instance_ids, 1-10). */
46+
search(params: {
47+
query: string
48+
ai_search_options: {
49+
instance_ids: string[]
50+
retrieval?: AiSearchRetrievalOptions
51+
reranking?: AiSearchRerankingOptions
52+
}
53+
}): Promise<AiSearchResult>
54+
}
55+
56+
export interface Env {
57+
ENVIRONMENT: 'development' | 'staging' | 'production'
58+
MCP_SERVER_NAME: string
59+
MCP_SERVER_VERSION: string
60+
MCP_METRICS: AnalyticsEngineDataset
61+
/** AI Search namespace binding, scoped to the `dev-stack` namespace. */
62+
AI_SEARCH: AiSearchNamespace
63+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { env } from 'cloudflare:test'
2+
3+
import { testStatelessMcpApp } from '@repo/mcp-common/src/test/stateless-app'
4+
5+
import { mcpHandler } from './stack-mcp.app'
6+
7+
import type { Env } from './stack-mcp.context'
8+
9+
testStatelessMcpApp<Env>({
10+
name: 'Cloudflare Developer Stack',
11+
handler: mcpHandler,
12+
env: env as unknown as Env,
13+
url: 'https://stack.mcp.cloudflare.com',
14+
expectedTools: ['list_libraries', 'search_docs'],
15+
requiredToolInputs: { search_docs: ['query'] },
16+
})
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { z } from 'zod'
2+
3+
import {
4+
LIBRARY_BY_INSTANCE,
5+
resolveLibraries,
6+
StackSearchQueryParam,
7+
toPublicLibrary,
8+
} from '../types/stack.types'
9+
10+
import type { McpRegistrationContext } from '@repo/mcp-common/src/registration-context'
11+
import type { AiSearchChunk, AiSearchNamespace } from '../stack-mcp.context'
12+
import type { StackLibrary } from '../types/stack.types'
13+
14+
const MAX_RESULTS = 10
15+
16+
interface RequiredEnv {
17+
AI_SEARCH: AiSearchNamespace
18+
}
19+
20+
function formatError(message: string) {
21+
return { content: [{ type: 'text' as const, text: `Error: ${message}` }], isError: true }
22+
}
23+
24+
function toResult(chunk: AiSearchChunk, fallback?: StackLibrary) {
25+
const lib = chunk.instance_id ? LIBRARY_BY_INSTANCE.get(chunk.instance_id) : fallback
26+
const title =
27+
typeof chunk.item.metadata?.title === 'string' ? chunk.item.metadata.title : chunk.item.key
28+
return {
29+
url: chunk.item.key,
30+
title,
31+
text: chunk.text,
32+
score: chunk.score,
33+
library: lib?.name ?? 'docs',
34+
}
35+
}
36+
37+
/**
38+
* Registers the Developer Stack tools for one request. The stack is scoped to the
39+
* subset selected via the `?libs=` URL param (or the whole stack when unscoped),
40+
* read from the request URL so both `/mcp` and `/sse` honor it.
41+
*/
42+
export function registerStackTools<Env extends RequiredEnv>(context: McpRegistrationContext<Env>) {
43+
const env = context.env
44+
const libsParam = new URL(context.request.url).searchParams.get('libs')
45+
const allowed = resolveLibraries(libsParam)
46+
47+
const allowedSlugs = allowed.map((l) => l.slug) as [string, ...string[]]
48+
const allowedIds = allowed.map((l) => l.instanceId)
49+
const bySlug = new Map(allowed.map((l) => [l.slug, l]))
50+
const names = allowed.map((l) => l.name).join(', ')
51+
52+
context.registerTool(
53+
'list_libraries',
54+
{
55+
description: `List the tools whose documentation this server can search (${names}), with each one's slug, name, source site, and a short description.
56+
57+
Use it to check whether a tool is covered, or to get the exact \`library\` slug for search_docs. You do not need to call it first: searching without a \`library\` already covers the whole stack.`,
58+
inputSchema: z.object({}),
59+
outputSchema: z.object({
60+
libraries: z.array(
61+
z.object({
62+
slug: z.string(),
63+
name: z.string(),
64+
source: z.string(),
65+
description: z.string(),
66+
})
67+
),
68+
}),
69+
annotations: { title: 'List developer-stack libraries', readOnlyHint: true },
70+
},
71+
async () => {
72+
const libraries = allowed.map(toPublicLibrary)
73+
return {
74+
structuredContent: { libraries },
75+
content: [
76+
{
77+
type: 'text' as const,
78+
text: allowed
79+
.map((l) => `- ${l.slug}: ${l.name} (${l.source}). ${l.description}`)
80+
.join('\n'),
81+
},
82+
],
83+
}
84+
}
85+
)
86+
87+
context.registerTool(
88+
'search_docs',
89+
{
90+
description: `Search current documentation for the tools in this developer stack (${names}) and get back the most relevant excerpts, each with a source link.
91+
92+
Reach for this whenever you are answering a question about, or writing code that uses, any of these tools. They change often, so your built-in knowledge of their APIs, configuration, and defaults is frequently out of date. Retrieve the docs instead of relying on memory, and ground the code you generate in what you find.
93+
94+
Cite the returned source URLs in your answer. Searches the whole stack by default; set \`library\` to one slug (from list_libraries) to focus on a single tool.`,
95+
inputSchema: z.object({
96+
query: StackSearchQueryParam,
97+
library: z
98+
.enum(allowedSlugs)
99+
.optional()
100+
.describe(
101+
'Optional. Restrict the search to a single library by its slug (from list_libraries). Omit to search the whole stack, which is usually best unless you already know the exact tool.'
102+
),
103+
}),
104+
outputSchema: z.object({
105+
results: z.array(
106+
z.object({
107+
url: z.string(),
108+
title: z.string(),
109+
text: z.string(),
110+
score: z.number(),
111+
library: z.string(),
112+
})
113+
),
114+
}),
115+
annotations: { title: 'Search developer-stack docs', readOnlyHint: true },
116+
},
117+
async ({ query, library }) => {
118+
try {
119+
const retrieval = { max_num_results: MAX_RESULTS }
120+
// Reranking is always on: it re-orders retrieved chunks for relevance.
121+
const reranking = { enabled: true }
122+
let results: Array<ReturnType<typeof toResult>>
123+
124+
if (library) {
125+
const lib = bySlug.get(library)
126+
if (!lib) return formatError(`Unknown library: ${library}`)
127+
const res = await env.AI_SEARCH.get(lib.instanceId).search({
128+
query,
129+
ai_search_options: { retrieval, reranking },
130+
})
131+
results = res.chunks.map((c) => toResult(c, lib))
132+
} else {
133+
const res = await env.AI_SEARCH.search({
134+
query,
135+
ai_search_options: { instance_ids: allowedIds, retrieval, reranking },
136+
})
137+
results = res.chunks.map((c) => toResult(c))
138+
}
139+
140+
const text =
141+
results.length === 0
142+
? 'No relevant documentation found.'
143+
: results
144+
.map(
145+
(r) =>
146+
`<result>\n<library>${r.library}</library>\n<url>${r.url}</url>\n<title>${r.title}</title>\n<text>\n${r.text}\n</text>\n</result>`
147+
)
148+
.join('\n')
149+
150+
return { structuredContent: { results }, content: [{ type: 'text' as const, text }] }
151+
} catch (e) {
152+
return formatError(e instanceof Error ? e.message : String(e))
153+
}
154+
}
155+
)
156+
}

0 commit comments

Comments
 (0)