@sarimarcus/content-sites-ui 0.35.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/package.json +3 -2
- package/src/layout/index.ts +2 -0
- package/src/layout/webMcp.ts +168 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.36.0 (2026-10-14)
|
|
6
|
+
|
|
7
|
+
- New `./webmcp` export (`setupWebMcp`, also re-exported from `./layout`): registers read-only WebMCP tools on `document.modelContext` when the browser exposes it, else does nothing. `search: true` adds `search_site` over the site's Pagefind index; `remoteEndpoint` + a lazy `remoteTools` import mirror a remote MCP server's tools through stateless JSON-RPC `tools/call`. No markup change; nothing loads until an agent calls a tool (SLA-3084).
|
|
8
|
+
|
|
5
9
|
## 0.35.0 (2026-10-13)
|
|
6
10
|
|
|
7
11
|
- No changes; lockstep release with core and tourism (SLA-3042).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "Universal Astro UI components, layout shell and design tokens shared by the content sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"./search-page": "./src/search/searchPage.ts",
|
|
38
38
|
"./navigation": "./src/navigation/index.ts",
|
|
39
39
|
"./search": "./src/search/index.ts",
|
|
40
|
+
"./webmcp": "./src/layout/webMcp.ts",
|
|
40
41
|
"./types": "./src/types/index.ts",
|
|
41
42
|
"./tailwind.css": "./src/styles/tailwind.css",
|
|
42
43
|
"./widths.css": "./src/styles/widths.css",
|
|
@@ -49,6 +50,6 @@
|
|
|
49
50
|
"@lucide/astro": "^1.23.0",
|
|
50
51
|
"astro": "^7.0.6",
|
|
51
52
|
"tailwindcss": "^4.1.18",
|
|
52
|
-
"@sarimarcus/content-sites-core": "0.
|
|
53
|
+
"@sarimarcus/content-sites-core": "0.36.0"
|
|
53
54
|
}
|
|
54
55
|
}
|
package/src/layout/index.ts
CHANGED
|
@@ -11,3 +11,5 @@ export type { ConsentType } from './ConsentAnalytics.astro';
|
|
|
11
11
|
export { shellResources, GTM_ORIGIN, SILKTIDE_BASE, SILKTIDE_STYLESHEET } from './resources.ts';
|
|
12
12
|
export { track, setupDelegatedTracking, setupFaqTracking, setupScrollDepthTracking, setupAnalytics } from './analytics.ts';
|
|
13
13
|
export type { AnalyticsOptions } from './analytics.ts';
|
|
14
|
+
export { setupWebMcp, buildWebMcpTools } from './webMcp.ts';
|
|
15
|
+
export type { WebMcpOptions, WebMcpRemoteTool } from './webMcp.ts';
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// @sarimarcus/content-sites-ui/webmcp (SLA-3084): read-only WebMCP tools (`document.modelContext`) for agents
|
|
2
|
+
// running in the visitor's browser. Nothing loads unless the browser exposes the API: the remote tool definitions
|
|
3
|
+
// and the Pagefind index are fetched lazily, and no request leaves the page until an agent calls a tool.
|
|
4
|
+
|
|
5
|
+
export interface WebMcpRemoteTool {
|
|
6
|
+
name: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
description: string;
|
|
9
|
+
inputSchema?: object;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface WebMcpOptions {
|
|
13
|
+
siteName: string;
|
|
14
|
+
/** Registers `search_site` over the site's Pagefind index. */
|
|
15
|
+
search?: boolean;
|
|
16
|
+
/** MCP endpoint the remote tools are called on (stateless JSON-RPC `tools/call`); its CORS must allow the site. */
|
|
17
|
+
remoteEndpoint?: string;
|
|
18
|
+
/** A dynamic import of the tool definitions, so they never reach a browser without the API. */
|
|
19
|
+
remoteTools?: () => Promise<{ default: WebMcpRemoteTool[] } | WebMcpRemoteTool[]>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WebMcpTool {
|
|
23
|
+
name: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
description: string;
|
|
26
|
+
inputSchema?: object;
|
|
27
|
+
annotations: { readOnlyHint: boolean };
|
|
28
|
+
execute: (input: Record<string, unknown>, client?: { signal?: AbortSignal }) => Promise<unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PagefindResult {
|
|
32
|
+
data(): Promise<{ url: string; meta?: Record<string, string>; excerpt?: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PagefindLike {
|
|
36
|
+
init?(): Promise<void>;
|
|
37
|
+
search(query: string): Promise<{ results: PagefindResult[] } | null>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WebMcpDeps {
|
|
41
|
+
fetch: typeof fetch;
|
|
42
|
+
loadPagefind: () => Promise<PagefindLike>;
|
|
43
|
+
origin: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SEARCH_LIMIT_MAX = 20;
|
|
47
|
+
|
|
48
|
+
const stripTags = (html = '') => html.replace(/<[^>]*>/g, '');
|
|
49
|
+
|
|
50
|
+
export function searchTool(siteName: string, deps: WebMcpDeps): WebMcpTool {
|
|
51
|
+
return {
|
|
52
|
+
name: 'search_site',
|
|
53
|
+
title: `Search ${siteName}`,
|
|
54
|
+
description: `Full-text search of every page on ${siteName}. Returns each match's URL, title and a short excerpt, best match first.`,
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
query: { type: 'string', description: 'Words to search for.' },
|
|
59
|
+
limit: { type: 'integer', minimum: 1, maximum: SEARCH_LIMIT_MAX, default: 8, description: 'Maximum number of results.' },
|
|
60
|
+
},
|
|
61
|
+
required: ['query'],
|
|
62
|
+
},
|
|
63
|
+
annotations: { readOnlyHint: true },
|
|
64
|
+
async execute(input) {
|
|
65
|
+
const query = String(input.query ?? '').trim();
|
|
66
|
+
if (!query) throw new Error('search_site: query is required');
|
|
67
|
+
const limit = Math.min(Math.max(Number(input.limit) || 8, 1), SEARCH_LIMIT_MAX);
|
|
68
|
+
const pagefind = await deps.loadPagefind();
|
|
69
|
+
const found = await pagefind.search(query);
|
|
70
|
+
const data = await Promise.all((found?.results ?? []).slice(0, limit).map((r) => r.data()));
|
|
71
|
+
return data.map((d) => ({
|
|
72
|
+
url: new URL(d.url, deps.origin).href,
|
|
73
|
+
title: d.meta?.title ?? '',
|
|
74
|
+
excerpt: stripTags(d.excerpt),
|
|
75
|
+
}));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Reads a JSON-RPC reply sent either as JSON or as a Streamable-HTTP SSE stream (last `data:` message wins). */
|
|
81
|
+
export async function readJsonRpc(res: Response): Promise<any> {
|
|
82
|
+
if (!res.headers.get('content-type')?.includes('text/event-stream')) return res.json();
|
|
83
|
+
const messages = (await res.text())
|
|
84
|
+
.split(/\r?\n/)
|
|
85
|
+
.filter((line) => line.startsWith('data:'))
|
|
86
|
+
.map((line) => JSON.parse(line.slice(5)));
|
|
87
|
+
if (!messages.length) throw new Error('empty event stream');
|
|
88
|
+
return messages[messages.length - 1];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function callRemoteTool(
|
|
92
|
+
endpoint: string,
|
|
93
|
+
name: string,
|
|
94
|
+
args: Record<string, unknown>,
|
|
95
|
+
fetchImpl: typeof fetch,
|
|
96
|
+
signal?: AbortSignal,
|
|
97
|
+
): Promise<unknown> {
|
|
98
|
+
const res = await fetchImpl(endpoint, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
|
|
101
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args ?? {} } }),
|
|
102
|
+
signal,
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
|
105
|
+
const body = await readJsonRpc(res);
|
|
106
|
+
if (body.error) throw new Error(`${name}: ${body.error.message ?? 'JSON-RPC error'}`);
|
|
107
|
+
const result = body.result ?? {};
|
|
108
|
+
const text: string | undefined = result.content?.find((c: { type: string }) => c.type === 'text')?.text;
|
|
109
|
+
if (result.isError) throw new Error(`${name}: ${text ?? 'tool error'}`);
|
|
110
|
+
if (result.structuredContent !== undefined) return result.structuredContent;
|
|
111
|
+
if (text === undefined) return null;
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(text);
|
|
114
|
+
} catch {
|
|
115
|
+
return text;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function remoteTool(def: WebMcpRemoteTool, endpoint: string, deps: WebMcpDeps): WebMcpTool {
|
|
120
|
+
return {
|
|
121
|
+
name: def.name,
|
|
122
|
+
title: def.title,
|
|
123
|
+
description: def.description,
|
|
124
|
+
inputSchema: def.inputSchema,
|
|
125
|
+
annotations: { readOnlyHint: true },
|
|
126
|
+
execute: (input, client) => callRemoteTool(endpoint, def.name, input, deps.fetch, client?.signal),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function buildWebMcpTools(options: WebMcpOptions, deps: WebMcpDeps): Promise<WebMcpTool[]> {
|
|
131
|
+
const tools: WebMcpTool[] = [];
|
|
132
|
+
if (options.search) tools.push(searchTool(options.siteName, deps));
|
|
133
|
+
if (options.remoteEndpoint && options.remoteTools) {
|
|
134
|
+
try {
|
|
135
|
+
const mod = await options.remoteTools();
|
|
136
|
+
const defs = Array.isArray(mod) ? mod : mod.default;
|
|
137
|
+
for (const def of defs) tools.push(remoteTool(def, options.remoteEndpoint, deps));
|
|
138
|
+
} catch {
|
|
139
|
+
// search still registers when the definitions chunk fails to load
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return tools;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type ModelContext = { registerTool(tool: WebMcpTool): Promise<void> | void };
|
|
146
|
+
|
|
147
|
+
export function setupWebMcp(options: WebMcpOptions): void {
|
|
148
|
+
const modelContext = (document as Document & { modelContext?: ModelContext }).modelContext;
|
|
149
|
+
if (!modelContext) return;
|
|
150
|
+
let pagefind: Promise<PagefindLike> | undefined;
|
|
151
|
+
const deps: WebMcpDeps = {
|
|
152
|
+
fetch: (...args) => fetch(...args),
|
|
153
|
+
origin: location.origin,
|
|
154
|
+
loadPagefind: () =>
|
|
155
|
+
(pagefind ??= (async () => {
|
|
156
|
+
// @ts-expect-error — emitted by the build, unresolvable at type-check time (as in searchPage.ts).
|
|
157
|
+
const mod = (await import(/* @vite-ignore */ '/pagefind/pagefind.js')) as PagefindLike;
|
|
158
|
+
await mod.init?.();
|
|
159
|
+
return mod;
|
|
160
|
+
})().catch((err) => {
|
|
161
|
+
pagefind = undefined;
|
|
162
|
+
throw err;
|
|
163
|
+
})),
|
|
164
|
+
};
|
|
165
|
+
buildWebMcpTools(options, deps)
|
|
166
|
+
.then((tools) => Promise.all(tools.map((tool) => Promise.resolve().then(() => modelContext.registerTool(tool)).catch(() => {}))))
|
|
167
|
+
.catch(() => {});
|
|
168
|
+
}
|