Skip to content

Commit f3f896f

Browse files
authored
feat: implement WebMCP API to expose site tools to AI agents (#30208)
* feat: implement WebMCP API to expose site tools to AI agents * chore: fix prettier formatting on webmcp files * refactor: extract Algolia constants to src/util/algolia.ts * formatting
1 parent 0a6ba2c commit f3f896f

6 files changed

Lines changed: 268 additions & 8 deletions

File tree

src/components/overrides/Head.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,4 +270,5 @@ metaTags.map((attrs) => {
270270
<script src="src/scripts/mermaid.ts"></script>
271271
<script src="src/scripts/analytics.ts"></script>
272272
<script src="src/scripts/explain-code.ts"></script>
273+
<script src="src/scripts/webmcp.ts"></script>
273274
<Default><slot /></Default>

src/components/search/InstantSearch.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { liteClient as algoliasearch } from "algoliasearch/lite";
2+
import { ALGOLIA_APP_ID, ALGOLIA_API_KEY, ALGOLIA_INDEX } from "~/util/algolia";
23
import { useEffect, useState } from "react";
34
import {
45
InstantSearch,
@@ -229,11 +230,8 @@ function FilterDropdown({
229230
export default function InstantSearchComponent() {
230231
return (
231232
<InstantSearch
232-
searchClient={algoliasearch(
233-
"D32WIYFTUF",
234-
"5cec275adc19dd3bc17617f7d9cf312a",
235-
)}
236-
indexName="prod_devdocs"
233+
searchClient={algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY)}
234+
indexName={ALGOLIA_INDEX}
237235
future={{
238236
preserveSharedStateOnUnmount: true,
239237
}}

src/env.d.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/// <reference types="astro/client" />
2+
3+
// WebMCP API — https://webmachinelearning.github.io/webmcp/
4+
// Chrome Early Preview Program implementation
5+
6+
interface ToolAnnotations {
7+
readOnlyHint?: boolean;
8+
}
9+
10+
interface ModelContextClient {
11+
requestUserInteraction(callback: () => Promise<unknown>): Promise<unknown>;
12+
}
13+
14+
type ToolExecuteCallback = (
15+
input: object,
16+
client: ModelContextClient,
17+
) => Promise<unknown>;
18+
19+
interface ModelContextTool {
20+
name: string;
21+
title?: string;
22+
description: string;
23+
inputSchema?: object;
24+
execute: ToolExecuteCallback;
25+
annotations?: ToolAnnotations;
26+
}
27+
28+
interface ModelContextRegisterToolOptions {
29+
signal?: AbortSignal;
30+
}
31+
32+
interface ModelContext {
33+
registerTool(
34+
tool: ModelContextTool,
35+
options?: ModelContextRegisterToolOptions,
36+
): void;
37+
}
38+
39+
interface Navigator {
40+
readonly modelContext?: ModelContext;
41+
}

src/plugins/docsearch/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { track } from "~/util/zaraz";
22
import type { DocSearchClientOptions } from "@astrojs/starlight-docsearch";
3+
import {
4+
ALGOLIA_APP_ID,
5+
ALGOLIA_API_KEY,
6+
ALGOLIA_INDEX,
7+
ALGOLIA_INDEX_STYLE_GUIDE,
8+
} from "~/util/algolia";
39

410
const isStyleGuide = window.location.pathname.startsWith("/style-guide/");
511

612
export default {
7-
appId: "D32WIYFTUF",
8-
apiKey: "5cec275adc19dd3bc17617f7d9cf312a",
9-
indexName: isStyleGuide ? "prod_devdocs_styleguide" : "prod_devdocs",
13+
appId: ALGOLIA_APP_ID,
14+
apiKey: ALGOLIA_API_KEY,
15+
indexName: isStyleGuide ? ALGOLIA_INDEX_STYLE_GUIDE : ALGOLIA_INDEX,
1016
insights: true,
1117
// Replace URL with the current origin so search
1218
// can be used in local development and previews.

src/scripts/webmcp.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* WebMCP — exposes Cloudflare Docs site tools to AI agents via the browser.
3+
* Spec: https://webmachinelearning.github.io/webmcp/
4+
*/
5+
6+
import { ALGOLIA_APP_ID, ALGOLIA_API_KEY, ALGOLIA_INDEX } from "~/util/algolia";
7+
const LLMS_TXT_URL = "https://developers.cloudflare.com/llms.txt";
8+
9+
// Cache for list-directories results — fetched once per session.
10+
let directoriesCache: DirectoryEntry[] | null = null;
11+
12+
interface DirectoryEntry {
13+
name: string;
14+
url: string;
15+
description: string;
16+
group: string;
17+
}
18+
19+
interface AlgoliaHit {
20+
objectID: string;
21+
url?: string;
22+
hierarchy?: {
23+
lvl0?: string;
24+
lvl1?: string;
25+
lvl2?: string;
26+
};
27+
content?: string;
28+
_snippetResult?: {
29+
content?: { value?: string };
30+
hierarchy?: {
31+
lvl1?: { value?: string };
32+
lvl2?: { value?: string };
33+
};
34+
};
35+
}
36+
37+
interface AlgoliaResponse {
38+
hits: AlgoliaHit[];
39+
}
40+
41+
// ---------------------------------------------------------------------------
42+
// Tool: search
43+
// ---------------------------------------------------------------------------
44+
async function executeSearch(input: object): Promise<unknown> {
45+
const { query, limit = 5 } = input as { query: string; limit?: number };
46+
47+
const clampedLimit = Math.min(Math.max(1, limit), 20);
48+
49+
const response = await fetch(
50+
`https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/${ALGOLIA_INDEX}/query`,
51+
{
52+
method: "POST",
53+
headers: {
54+
"X-Algolia-Application-Id": ALGOLIA_APP_ID,
55+
"X-Algolia-API-Key": ALGOLIA_API_KEY,
56+
"Content-Type": "application/json",
57+
},
58+
body: JSON.stringify({
59+
query,
60+
filters: "type:content",
61+
hitsPerPage: clampedLimit,
62+
attributesToRetrieve: ["url", "hierarchy", "content"],
63+
attributesToSnippet: ["content:20", "hierarchy.lvl1:10"],
64+
}),
65+
},
66+
);
67+
68+
if (!response.ok) {
69+
throw new Error(`Search failed: ${response.status} ${response.statusText}`);
70+
}
71+
72+
const data = (await response.json()) as AlgoliaResponse;
73+
74+
return data.hits.map((hit) => ({
75+
title:
76+
hit.hierarchy?.lvl2 ??
77+
hit.hierarchy?.lvl1 ??
78+
hit.hierarchy?.lvl0 ??
79+
"Untitled",
80+
url: hit.url ?? "",
81+
snippet:
82+
hit._snippetResult?.content?.value ??
83+
hit._snippetResult?.hierarchy?.lvl1?.value ??
84+
hit._snippetResult?.hierarchy?.lvl2?.value ??
85+
hit.content?.slice(0, 200) ??
86+
"",
87+
}));
88+
}
89+
90+
// ---------------------------------------------------------------------------
91+
// Tool: list-directories
92+
// ---------------------------------------------------------------------------
93+
async function executeListDirectories(
94+
_input: object,
95+
): Promise<DirectoryEntry[]> {
96+
if (directoriesCache) {
97+
return directoriesCache;
98+
}
99+
100+
const response = await fetch(LLMS_TXT_URL);
101+
if (!response.ok) {
102+
throw new Error(
103+
`Failed to fetch directory: ${response.status} ${response.statusText}`,
104+
);
105+
}
106+
107+
const text = await response.text();
108+
const results: DirectoryEntry[] = [];
109+
let currentGroup = "";
110+
111+
for (const line of text.split("\n")) {
112+
// Group headings: "## Application performance"
113+
const headingMatch = /^##\s+(.+)$/.exec(line);
114+
if (headingMatch) {
115+
currentGroup = headingMatch[1].trim();
116+
continue;
117+
}
118+
119+
// Product entries: "- [Name](url): description"
120+
const entryMatch = /^-\s+\[([^\]]+)\]\(([^)]+)\)(?::\s+(.*))?$/.exec(line);
121+
if (entryMatch) {
122+
const name = entryMatch[1].trim();
123+
// Convert per-product llms.txt URL to the docs root URL
124+
const llmsUrl = entryMatch[2].trim();
125+
const url = llmsUrl.replace(/\/llms\.txt$/, "/");
126+
const description = (entryMatch[3] ?? "").trim();
127+
128+
results.push({ name, url, description, group: currentGroup });
129+
}
130+
}
131+
132+
directoriesCache = results;
133+
return results;
134+
}
135+
136+
// ---------------------------------------------------------------------------
137+
// Registration / lifecycle
138+
// ---------------------------------------------------------------------------
139+
function registerTools(): void {
140+
if (!("modelContext" in navigator) || !navigator.modelContext) {
141+
return;
142+
}
143+
144+
const controller = new AbortController();
145+
const { signal } = controller;
146+
147+
navigator.modelContext.registerTool(
148+
{
149+
name: "search",
150+
title: "Search Cloudflare Docs",
151+
description:
152+
"Full-text search across Cloudflare developer documentation. Returns matching pages with titles, URLs, and content snippets. Use this to find documentation on any Cloudflare product or feature.",
153+
inputSchema: {
154+
type: "object",
155+
properties: {
156+
query: {
157+
type: "string",
158+
description: "The search query",
159+
},
160+
limit: {
161+
type: "integer",
162+
description:
163+
"Maximum number of results to return (1–20, default 5)",
164+
default: 5,
165+
minimum: 1,
166+
maximum: 20,
167+
},
168+
},
169+
required: ["query"],
170+
},
171+
execute: executeSearch,
172+
annotations: { readOnlyHint: true },
173+
},
174+
{ signal },
175+
);
176+
177+
navigator.modelContext.registerTool(
178+
{
179+
name: "list-directories",
180+
title: "List Cloudflare Docs Products",
181+
description:
182+
"Returns a list of all Cloudflare products available in the developer documentation, including each product's name, docs URL, short description, and category group. Use this to discover what products exist before searching or navigating.",
183+
inputSchema: {
184+
type: "object",
185+
properties: {},
186+
},
187+
execute: executeListDirectories,
188+
annotations: { readOnlyHint: true },
189+
},
190+
{ signal },
191+
);
192+
193+
// Unregister all tools on Astro page transitions.
194+
document.addEventListener(
195+
"astro:before-swap",
196+
() => {
197+
controller.abort();
198+
},
199+
{ once: true },
200+
);
201+
}
202+
203+
// Register on initial load and after each Astro page navigation.
204+
document.addEventListener("astro:page-load", registerTools);
205+
206+
if (document.readyState === "loading") {
207+
document.addEventListener("DOMContentLoaded", registerTools);
208+
} else {
209+
registerTools();
210+
}

src/util/algolia.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const ALGOLIA_APP_ID = "D32WIYFTUF";
2+
export const ALGOLIA_API_KEY = "5cec275adc19dd3bc17617f7d9cf312a";
3+
export const ALGOLIA_INDEX = "prod_devdocs";
4+
export const ALGOLIA_INDEX_STYLE_GUIDE = "prod_devdocs_styleguide";

0 commit comments

Comments
 (0)