1// Registers WebMCP tools (https://webmachinelearning.github.io/webmcp/) that help AI agents in the
2// browser search the documentation and read pages as Markdown. Browsers without WebMCP skip this.
3import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'
4import siteConfig from '@generated/docusaurus.config'
5
6interface ToolExecuteOptions {
7 signal?: AbortSignal
8}
9
10interface ModelContextTool {
11 name: string
12 title?: string
13 description: string
14 inputSchema?: object
15 annotations?: { readOnlyHint?: boolean }
16 execute: (input: Record<string, unknown>, options?: ToolExecuteOptions) => Promise<unknown>
17}
18
19interface ModelContext {
20 registerTool(tool: ModelContextTool): unknown
21}
22
23declare global {
24 interface Document {
25 modelContext?: ModelContext
26 }
27 interface Navigator {
28 modelContext?: ModelContext
29 }
30}
31
32interface SearchHit {
33 url: string
34 hierarchy?: Record<string, string | null>
35 _snippetResult?: { content?: { value?: string } }
36}
37
38const algolia = siteConfig.themeConfig.algolia as {
39 appId: string
40 apiKey: string
41 indexName: string
42}
43const latestDocsVersion = String(siteConfig.customFields?.latestDocsVersion ?? 'current')
44// Search results use the canonical origin, even on a preview or local deployment.
45const CANONICAL_ORIGIN = new URL(siteConfig.url).origin
46const HIERARCHY_LEVELS = ['lvl0', 'lvl1', 'lvl2', 'lvl3', 'lvl4', 'lvl5', 'lvl6']
47
48async function searchDocs(query: string, limit: number, signal?: AbortSignal) {
49 const credentials = new URLSearchParams({
50 'x-algolia-application-id': algolia.appId,
51 'x-algolia-api-key': algolia.apiKey
52 })
53 const response = await fetch(
54 `https://${algolia.appId}-dsn.algolia.net/1/indexes/${encodeURIComponent(algolia.indexName)}/query?${credentials}`,
55 {
56 method: 'POST',
57 // A CORS-safelisted content type avoids a preflight request, as Algolia's own client does.
58 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
59 body: JSON.stringify({
60 query,
61 hitsPerPage: Math.min(Math.max(Math.trunc(limit) || 5, 1), 20),
62 facetFilters: [
63 ['language:en'],
64 [`docusaurus_tag:docs-default-${latestDocsVersion}`, 'docusaurus_tag:default']
65 ],
66 attributesToRetrieve: ['url', 'hierarchy'],
67 attributesToSnippet: ['content:40'],
68 snippetEllipsisText: '…'
69 }),
70 signal
71 }
72 )
73 if (!response.ok) {
74 throw new Error(`Search failed with HTTP ${response.status}.`)
75 }
76
77 const { hits } = (await response.json()) as { hits: SearchHit[] }
78 return {
79 results: hits.map((hit) => {
80 const levels = HIERARCHY_LEVELS.map((level) => toText(hit.hierarchy?.[level]))
81 const snippet = hit._snippetResult?.content?.value
82 return {
83 title: levels
84 .filter((level, index) => level && level !== levels[index - 1])
85 .join(' › '),
86 url: hit.url,
87 excerpt: snippet ? toText(snippet) : undefined
88 }
89 })
90 }
91}
92
93// The index stores HTML-escaped text, and snippets wrap matches in highlight tags.
94function toText(html: string | null | undefined) {
95 if (!html) {
96 return ''
97 }
98 const text = new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''
99 return text.replace(/\u200b/g, '').trim()
100}
101
102async function getPageMarkdown(page: string, signal?: AbortSignal) {
103 let url = new URL(page, window.location.origin)
104 // Read a canonical URL, such as a search_docs result, from the deployment that serves this page.
105 if (url.origin === CANONICAL_ORIGIN) {
106 url = new URL(`${url.pathname}${url.search}`, window.location.origin)
107 }
108 if (url.origin !== window.location.origin) {
109 throw new Error(`Only pages of this site are available, not ${url.origin}.`)
110 }
111
112 const response = await fetch(url, { headers: { Accept: 'text/markdown' }, signal })
113 const contentType = response.headers.get('Content-Type') ?? ''
114 if (!response.ok || !/^text\/(markdown|plain)/.test(contentType)) {
115 throw new Error(`No Markdown version is available for ${url.pathname}.`)
116 }
117 return { url: url.href, markdown: await response.text() }
118}
119
120const tools: ModelContextTool[] = [
121 {
122 name: 'search_docs',
123 title: 'Search Spice.ai docs',
124 description:
125 'Search the Spice.ai OSS documentation for the latest release. Returns matching sections with their titles, URLs, and excerpts. Use get_page_markdown to read a result in full.',
126 inputSchema: {
127 type: 'object',
128 properties: {
129 query: {
130 type: 'string',
131 description: 'Search terms, for example "cayenne refresh mode".'
132 },
133 limit: {
134 type: 'integer',
135 minimum: 1,
136 maximum: 20,
137 default: 5,
138 description: 'Maximum number of results.'
139 }
140 },
141 required: ['query']
142 },
143 annotations: { readOnlyHint: true },
144 execute: ({ query, limit }, options) =>
145 searchDocs(String(query), Number(limit ?? 5), options?.signal)
146 },
147 {
148 name: 'get_page_markdown',
149 title: 'Read a Spice.ai docs page',
150 description:
151 'Get a page of this site as Markdown, from a path such as /docs/getting-started or a search_docs result URL. /llms.txt lists every documentation page.',
152 inputSchema: {
153 type: 'object',
154 properties: {
155 path: {
156 type: 'string',
157 description: 'Path or URL of a page on this site, including a search_docs result URL.'
158 }
159 },
160 required: ['path']
161 },
162 annotations: { readOnlyHint: true },
163 execute: ({ path }, options) => getPageMarkdown(String(path), options?.signal)
164 }
165]
166
167async function registerTool(modelContext: ModelContext, tool: ModelContextTool) {
168 try {
169 // registerTool() returns a promise in the current specification; earlier builds return nothing.
170 await modelContext.registerTool(tool)
171 } catch (error) {
172 console.warn(`WebMCP: could not register the ${tool.name} tool.`, error)
173 }
174}
175
176if (ExecutionEnvironment.canUseDOM) {
177 // Chrome builds before the current specification expose the API on navigator.
178 const modelContext = document.modelContext ?? navigator.modelContext
179 if (modelContext) {
180 for (const tool of tools) {
181 void registerTool(modelContext, tool)
182 }
183 }
184}
185
1// Registers WebMCP tools (https://webmachinelearning.github.io/webmcp/) that help AI agents in the
2// browser search the documentation and read pages as Markdown. Browsers without WebMCP skip this.
3import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'
4import siteConfig from '@generated/docusaurus.config'
5
6interface ToolExecuteOptions {
7 signal?: AbortSignal
8}
9
10interface ModelContextTool {
11 name: string
12 title?: string
13 description: string
14 inputSchema?: object
15 annotations?: { readOnlyHint?: boolean }
16 execute: (input: Record<string, unknown>, options?: ToolExecuteOptions) => Promise<unknown>
17}
18
19interface ModelContext {
20 registerTool(tool: ModelContextTool): unknown
21}
22
23declare global {
24 interface Document {
25 modelContext?: ModelContext
26 }
27 interface Navigator {
28 modelContext?: ModelContext
29 }
30}
31
32interface SearchHit {
33 url: string
34 hierarchy?: Record<string, string | null>
35 _snippetResult?: { content?: { value?: string } }
36}
37
38const algolia = siteConfig.themeConfig.algolia as {
39 appId: string
40 apiKey: string
41 indexName: string
42}
43const latestDocsVersion = String(siteConfig.customFields?.latestDocsVersion ?? 'current')
44// Search results use the canonical origin, even on a preview or local deployment.
45const CANONICAL_ORIGIN = new URL(siteConfig.url).origin
46const HIERARCHY_LEVELS = ['lvl0', 'lvl1', 'lvl2', 'lvl3', 'lvl4', 'lvl5', 'lvl6']
47
48async function searchDocs(query: string, limit: number, signal?: AbortSignal) {
49 const credentials = new URLSearchParams({
50 'x-algolia-application-id': algolia.appId,
51 'x-algolia-api-key': algolia.apiKey
52 })
53 const response = await fetch(
54 `https://${algolia.appId}-dsn.algolia.net/1/indexes/${encodeURIComponent(algolia.indexName)}/query?${credentials}`,
55 {
56 method: 'POST',
57 // A CORS-safelisted content type avoids a preflight request, as Algolia's own client does.
58 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
59 body: JSON.stringify({
60 query,
61 hitsPerPage: Math.min(Math.max(Math.trunc(limit) || 5, 1), 20),
62 facetFilters: [
63 ['language:en'],
64 [`docusaurus_tag:docs-default-${latestDocsVersion}`, 'docusaurus_tag:default']
65 ],
66 attributesToRetrieve: ['url', 'hierarchy'],
67 attributesToSnippet: ['content:40'],
68 snippetEllipsisText: '…'
69 }),
70 signal
71 }
72 )
73 if (!response.ok) {
74 throw new Error(`Search failed with HTTP ${response.status}.`)
75 }
76
77 const { hits } = (await response.json()) as { hits: SearchHit[] }
78 return {
79 results: hits.map((hit) => {
80 const levels = HIERARCHY_LEVELS.map((level) => toText(hit.hierarchy?.[level]))
81 const snippet = hit._snippetResult?.content?.value
82 return {
83 title: levels
84 .filter((level, index) => level && level !== levels[index - 1])
85 .join(' › '),
86 url: hit.url,
87 excerpt: snippet ? toText(snippet) : undefined
88 }
89 })
90 }
91}
92
93// The index stores HTML-escaped text, and snippets wrap matches in highlight tags.
94function toText(html: string | null | undefined) {
95 if (!html) {
96 return ''
97 }
98 const text = new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''
99 return text.replace(/\u200b/g, '').trim()
100}
101
102async function getPageMarkdown(page: string, signal?: AbortSignal) {
103 let url = new URL(page, window.location.origin)
104 // Read a canonical URL, such as a search_docs result, from the deployment that serves this page.
105 if (url.origin === CANONICAL_ORIGIN) {
106 url = new URL(`${url.pathname}${url.search}`, window.location.origin)
107 }
108 if (url.origin !== window.location.origin) {
109 throw new Error(`Only pages of this site are available, not ${url.origin}.`)
110 }
111
112 const response = await fetch(url, { headers: { Accept: 'text/markdown' }, signal })
113 const contentType = response.headers.get('Content-Type') ?? ''
114 if (!response.ok || !/^text\/(markdown|plain)/.test(contentType)) {
115 throw new Error(`No Markdown version is available for ${url.pathname}.`)
116 }
117 return { url: url.href, markdown: await response.text() }
118}
119
120const tools: ModelContextTool[] = [
121 {
122 name: 'search_docs',
123 title: 'Search Spice.ai docs',
124 description:
125 'Search the Spice.ai OSS documentation for the latest release. Returns matching sections with their titles, URLs, and excerpts. Use get_page_markdown to read a result in full.',
126 inputSchema: {
127 type: 'object',
128 properties: {
129 query: {
130 type: 'string',
131 description: 'Search terms, for example "cayenne refresh mode".'
132 },
133 limit: {
134 type: 'integer',
135 minimum: 1,
136 maximum: 20,
137 default: 5,
138 description: 'Maximum number of results.'
139 }
140 },
141 required: ['query']
142 },
143 annotations: { readOnlyHint: true },
144 execute: ({ query, limit }, options) =>
145 searchDocs(String(query), Number(limit ?? 5), options?.signal)
146 },
147 {
148 name: 'get_page_markdown',
149 title: 'Read a Spice.ai docs page',
150 description:
151 'Get a page of this site as Markdown, from a path such as /docs/getting-started or a search_docs result URL. /llms.txt lists every documentation page.',
152 inputSchema: {
153 type: 'object',
154 properties: {
155 path: {
156 type: 'string',
157 description: 'Path or URL of a page on this site, including a search_docs result URL.'
158 }
159 },
160 required: ['path']
161 },
162 annotations: { readOnlyHint: true },
163 execute: ({ path }, options) => getPageMarkdown(String(path), options?.signal)
164 }
165]
166
167async function registerTool(modelContext: ModelContext, tool: ModelContextTool) {
168 try {
169 // registerTool() returns a promise in the current specification; earlier builds return nothing.
170 await modelContext.registerTool(tool)
171 } catch (error) {
172 console.warn(`WebMCP: could not register the ${tool.name} tool.`, error)
173 }
174}
175
176if (ExecutionEnvironment.canUseDOM) {
177 // Chrome builds before the current specification expose the API on navigator.
178 const modelContext = document.modelContext ?? navigator.modelContext
179 if (modelContext) {
180 for (const tool of tools) {
181 void registerTool(modelContext, tool)
182 }
183 }
184}
185
