Skip to content

Commit e8416b6

Browse files
kaankacarCopilotElliotFriendCopilot
authored
Expose WebMCP search_docs tool for in-browser AI agents (#2678)
* Initial plan * Expose a WebMCP search_docs tool for in-browser AI agents Adds a Docusaurus plugin (src/plugins/webmcp) whose client module registers a single WebMCP tool, search_docs(query), when the experimental modelContext API is present — navigator.modelContext or document.modelContext, preferring registerTool() with a provideContext() fallback. The tool queries the site's existing Algolia index over REST with the same contextual facet filters DocSearch uses (language + docusaurus_tag) and returns title/url/snippet results as JSON. Silent no-op in browsers without the API. * Strip snippet markup to a fixpoint CodeQL flagged the single-pass tag strip (js/incomplete-multi-character- sanitization): overlapping fragments like '<scr<script>ipt>' could survive one replace. Loop the replacement until stable instead. * Simplify WebMCP integration: clientModules instead of a plugin The dedicated plugin only existed to load a client module and smuggle the Algolia credentials to it through an injected window global. Docusaurus already has a top-level clientModules option for the former, and the client module can read themeConfig directly via @generated/docusaurus.config for the latter. One file and one config line now; no window global, no injected script tag. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Elliot Voris <elliot@stellar.org> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent fe00648 commit e8416b6

2 files changed

Lines changed: 186 additions & 0 deletions

File tree

docusaurus.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const config: Config = {
3636
async: true,
3737
},
3838
],
39+
// Exposes a WebMCP `search_docs` tool to in-browser AI agents (#2556).
40+
clientModules: ['./src/clientModules/webmcp.ts'],
3941
plugins: [
4042
"docusaurus-plugin-sass",
4143
[

src/clientModules/webmcp.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Registers a WebMCP `search_docs` tool so in-browser AI agents can search
2+
// these docs (https://webmachinelearning.github.io/webmcp/). WebMCP is
3+
// per-page browser tooling — client-side JS, not a network MCP server. The
4+
// API only exists behind an experimental flag (Chrome EPP / W3C draft), so in
5+
// every other browser this module must be a silent no-op.
6+
//
7+
// Loaded via the top-level `clientModules` option in docusaurus.config.ts.
8+
9+
import siteConfig from '@generated/docusaurus.config';
10+
11+
type AlgoliaCredentials = {
12+
appId: string;
13+
apiKey: string;
14+
indexName: string;
15+
};
16+
17+
// The same search-only Algolia credentials every DocSearch request already
18+
// ships to the browser — nothing here is secret.
19+
const algolia = siteConfig.themeConfig.algolia as
20+
| AlgoliaCredentials
21+
| undefined;
22+
23+
type AlgoliaHit = {
24+
hierarchy?: Record<string, string | null>;
25+
url?: string;
26+
content?: string | null;
27+
_snippetResult?: { content?: { value?: string } };
28+
};
29+
30+
type ToolContent = { content: Array<{ type: 'text'; text: string }> };
31+
32+
type ModelContextTool = {
33+
name: string;
34+
description: string;
35+
inputSchema: object;
36+
annotations?: { readOnlyHint?: boolean };
37+
execute: (input: { query?: unknown }) => Promise<ToolContent>;
38+
};
39+
40+
// The draft spec has moved between navigator.modelContext and
41+
// document.modelContext, and between registerTool() and provideContext().
42+
// Chrome's EPP (and the scanners that grade agent-readiness) use
43+
// navigator.modelContext.registerTool; the current W3C draft says
44+
// document.modelContext. Probe every surface so the tool registers wherever
45+
// the running browser put the API.
46+
type ModelContext = {
47+
registerTool?: (tool: ModelContextTool) => unknown;
48+
provideContext?: (context: { tools: ModelContextTool[] }) => unknown;
49+
};
50+
51+
function textResult(text: string): ToolContent {
52+
return { content: [{ type: 'text', text }] };
53+
}
54+
55+
// Strip markup to a fixpoint so overlapping fragments like `<scr<script>ipt>`
56+
// can't survive a single pass (CodeQL js/incomplete-multi-character-sanitization).
57+
function stripMarkup(value: string): string {
58+
let text = value;
59+
let previous;
60+
do {
61+
previous = text;
62+
text = text.replace(/<[^>]*>/g, '');
63+
} while (text !== previous);
64+
return text;
65+
}
66+
67+
async function searchDocs(
68+
query: string,
69+
credentials: AlgoliaCredentials,
70+
): Promise<string> {
71+
// Same contextual facets DocSearch uses on this site: English records from
72+
// the docs plugin plus untagged (default) pages, excluding blog-index noise.
73+
const language = document.documentElement.lang?.split('-')[0] || 'en';
74+
const params = new URLSearchParams({
75+
query,
76+
hitsPerPage: '8',
77+
attributesToRetrieve: JSON.stringify(['hierarchy', 'url', 'content']),
78+
attributesToSnippet: JSON.stringify(['content:40']),
79+
snippetEllipsisText: '…',
80+
highlightPreTag: '',
81+
highlightPostTag: '',
82+
facetFilters: JSON.stringify([
83+
`language:${language}`,
84+
['docusaurus_tag:default', 'docusaurus_tag:docs-default-current'],
85+
]),
86+
});
87+
88+
const response = await fetch(
89+
`https://${credentials.appId}-dsn.algolia.net/1/indexes/${encodeURIComponent(credentials.indexName)}/query`,
90+
{
91+
method: 'POST',
92+
headers: {
93+
'Content-Type': 'application/json',
94+
'X-Algolia-Application-Id': credentials.appId,
95+
'X-Algolia-API-Key': credentials.apiKey,
96+
},
97+
body: JSON.stringify({ params: params.toString() }),
98+
},
99+
);
100+
if (!response.ok) {
101+
throw new Error(`Algolia search returned HTTP ${response.status}`);
102+
}
103+
104+
const data = (await response.json()) as { nbHits?: number; hits?: AlgoliaHit[] };
105+
const results = (data.hits ?? []).map((hit) => {
106+
const hierarchy = hit.hierarchy ?? {};
107+
const title =
108+
[hierarchy.lvl0, hierarchy.lvl1, hierarchy.lvl2, hierarchy.lvl3]
109+
.filter(Boolean)
110+
.join(' › ') || hit.url || '';
111+
// Snippets come back with highlight markup even with empty highlight
112+
// tags on some index configs — strip any leftover tags for plain text.
113+
const snippet = stripMarkup(
114+
hit._snippetResult?.content?.value ?? hit.content ?? '',
115+
);
116+
return { title, url: hit.url ?? '', snippet };
117+
});
118+
119+
return JSON.stringify({ query, totalHits: data.nbHits ?? results.length, results });
120+
}
121+
122+
const searchDocsTool: ModelContextTool = {
123+
name: 'search_docs',
124+
description:
125+
'Search the Stellar developer documentation (developers.stellar.org) and ' +
126+
'return matching pages and sections as JSON — title, url, and a ' +
127+
'plain-text snippet for each result.',
128+
inputSchema: {
129+
type: 'object',
130+
properties: {
131+
query: {
132+
type: 'string',
133+
description:
134+
'Plain-text search query, e.g. "soroban contract deployment".',
135+
},
136+
},
137+
required: ['query'],
138+
},
139+
annotations: { readOnlyHint: true },
140+
async execute(input) {
141+
try {
142+
const query = typeof input?.query === 'string' ? input.query.trim() : '';
143+
if (!query) {
144+
return textResult('Error: `query` must be a non-empty string.');
145+
}
146+
if (!algolia) {
147+
return textResult('Error: search is not configured on this page.');
148+
}
149+
return textResult(await searchDocs(query, algolia));
150+
} catch (error) {
151+
return textResult(`Error searching docs: ${String(error)}`);
152+
}
153+
},
154+
};
155+
156+
if (typeof window !== 'undefined') {
157+
const surfaces = [
158+
(navigator as Navigator & { modelContext?: ModelContext }).modelContext,
159+
(document as Document & { modelContext?: ModelContext }).modelContext,
160+
];
161+
// Register once per distinct surface (they alias in a real implementation).
162+
const seen = new Set<ModelContext>();
163+
for (const modelContext of surfaces) {
164+
if (!modelContext || seen.has(modelContext)) {
165+
continue;
166+
}
167+
seen.add(modelContext);
168+
try {
169+
if (typeof modelContext.registerTool === 'function') {
170+
void Promise.resolve(modelContext.registerTool(searchDocsTool)).catch(
171+
() => {},
172+
);
173+
} else if (typeof modelContext.provideContext === 'function') {
174+
void Promise.resolve(
175+
modelContext.provideContext({ tools: [searchDocsTool] }),
176+
).catch(() => {});
177+
}
178+
} catch {
179+
// Experimental API — registration must never break the page.
180+
}
181+
}
182+
}
183+
184+
export {};

0 commit comments

Comments
 (0)