|
| 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 | +} |
0 commit comments