@olonjs/cli 3.0.165 → 3.0.167

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.
@@ -19,12 +19,13 @@ import {
19
19
  import { loadVisitorPage } from '@olonjs/next/server';
20
20
  import { EmptyTenantView } from '@/components/empty-tenant';
21
21
  import { WebMcpVisitorRuntime } from '@/components/webmcp/WebMcpVisitorRuntime';
22
- import { CollectionRegistry } from '@/lib/CollectionRegistry';
23
22
  import { buildVisitorWebPageJsonLd } from '@/lib/buildVisitorWebPageJsonLd';
24
- import { getFileCollections } from '@/lib/loaders/getFileCollections';
25
23
  import { getFilePages } from '@/lib/loaders/getFilePages';
26
24
  import { getFileSiteBundle } from '@/lib/loaders/getFileSiteConfig';
25
+ import { loadVisitorPageBundle } from '@/lib/loaders/loadVisitorPageBundle';
27
26
  import { resolveVisitorShell } from '@/lib/resolveVisitorShell';
27
+ import { resolvePublicToolNames } from '@/lib/webmcp/resolvePublicToolNames';
28
+ import { sectionPublicTools } from '@/lib/webmcp/toolRegistry';
28
29
  import { VisitorSection } from '@/lib/VisitorSection';
29
30
 
30
31
  export const dynamic = 'force-dynamic';
@@ -89,7 +90,10 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
89
90
  notFound();
90
91
  }
91
92
 
92
- const collections = getFileCollections();
93
+ const { collections, collectionSchemas } = await loadVisitorPageBundle({
94
+ page: result.page,
95
+ params: result.params,
96
+ });
93
97
  const { siteConfig, menuConfig, themeConfig } = getFileSiteBundle();
94
98
  const collectionContext = resolveCollectionContext(result.page, result.params, collections);
95
99
 
@@ -99,7 +103,7 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
99
103
  themeConfig,
100
104
  menuConfig,
101
105
  collections,
102
- collectionSchemas: CollectionRegistry as never,
106
+ collectionSchemas: collectionSchemas as never,
103
107
  collectionContext,
104
108
  refDocuments: {
105
109
  'menu.json': menuConfig,
@@ -113,6 +117,7 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
113
117
  const pageNum = Number(firstSearchValue(query.page) ?? '1') || 1;
114
118
  const shell = resolveVisitorShell(page, resolved.siteConfig ?? siteConfig);
115
119
  const sectionExtras = { authorId, page: pageNum, pathname };
120
+ const publicToolNames = resolvePublicToolNames(page.sections, sectionPublicTools as Record<string, readonly string[]>);
116
121
  const title = page.meta?.title ?? result.registrySlug;
117
122
  const description = typeof page.meta?.description === 'string' ? page.meta.description : '';
118
123
  const jsonLd = buildVisitorWebPageJsonLd({
@@ -123,7 +128,7 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
123
128
 
124
129
  return (
125
130
  <>
126
- <WebMcpVisitorRuntime />
131
+ <WebMcpVisitorRuntime toolNames={publicToolNames} />
127
132
  <link rel="mcp-manifest" href={buildPageManifestHref(requestSlug)} />
128
133
  <link rel="olon-contract" href={buildPageContractHref(requestSlug)} />
129
134
  <script
@@ -213,27 +218,50 @@ mkdir -p "app/api/public-collection/[source]"
213
218
  mkdir -p "app/api/public-collection/[source]/[file]"
214
219
  echo "Creating app/api/public-collection/[source]/[file]/route.ts..."
215
220
  cat << 'END_OF_FILE_CONTENT' > "app/api/public-collection/[source]/[file]/route.ts"
216
- import { NextResponse } from 'next/server';
217
- import { resolvePublicCollectionDocument } from '@/lib/loaders/publishedContent';
218
- import { loadRuntimeSurfaceForSite } from '@/lib/webmcp/runtime/loadRuntimeSurface';
219
-
220
- export const dynamic = 'force-dynamic';
221
-
222
- /** GET /collections/{source}/{source}.json (rewrite) — collection document, runtime. */
223
- export async function GET(_request: Request, context: { params: Promise<{ source: string; file: string }> }) {
224
- try {
225
- const { source, file } = await context.params;
226
- const { bundle } = loadRuntimeSurfaceForSite();
227
- const doc = resolvePublicCollectionDocument(bundle, decodeURIComponent(source), decodeURIComponent(file));
228
- if (!doc) return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
229
- return NextResponse.json(doc);
230
- } catch (error) {
231
- return NextResponse.json(
232
- { error: error instanceof Error ? error.message : 'Collection JSON failed' },
233
- { status: 500 },
234
- );
235
- }
236
- }
221
+ import { NextResponse } from 'next/server';
222
+ import { resolveCollectionSources } from '@olonjs/sources';
223
+ import { resolvePublicCollectionDocument } from '@/lib/loaders/publishedContent';
224
+ import { collectionSources } from '@/sources';
225
+ import { loadRuntimeSurfaceForSite } from '@/lib/webmcp/runtime/loadRuntimeSurface';
226
+
227
+ export const dynamic = 'force-dynamic';
228
+
229
+ /**
230
+ * GET /collections/{source}/{source}.json (rewrite) — collection document, runtime.
231
+ *
232
+ * File-backed collections are read from the content bundle; EXTERNAL sources
233
+ * (ADR-0018) are projected on demand so the contract's `dataHref` resolves.
234
+ * Note: resolution here has no route params — parameterized sources should be
235
+ * resolved at the page level (see loadPublicPageBundleForRequest).
236
+ */
237
+ export async function GET(_request: Request, context: { params: Promise<{ source: string; file: string }> }) {
238
+ try {
239
+ const { source, file } = await context.params;
240
+ const sourceName = decodeURIComponent(source);
241
+
242
+ if (sourceName in collectionSources) {
243
+ const resolved = await resolveCollectionSources({
244
+ sources: collectionSources as never,
245
+ needed: [sourceName],
246
+ });
247
+ const projected = resolved.collections[sourceName];
248
+ if (!projected) {
249
+ return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
250
+ }
251
+ return NextResponse.json(projected);
252
+ }
253
+
254
+ const { bundle } = loadRuntimeSurfaceForSite();
255
+ const doc = resolvePublicCollectionDocument(bundle, sourceName, decodeURIComponent(file));
256
+ if (!doc) return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
257
+ return NextResponse.json(doc);
258
+ } catch (error) {
259
+ return NextResponse.json(
260
+ { error: error instanceof Error ? error.message : 'Collection JSON failed' },
261
+ { status: 500 },
262
+ );
263
+ }
264
+ }
237
265
 
238
266
  END_OF_FILE_CONTENT
239
267
  mkdir -p "app/api/public-config"
@@ -761,10 +789,11 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
761
789
  "dist:dna": "npm run dist"
762
790
  },
763
791
  "dependencies": {
764
- "@olonjs/core": "^2.0.1",
765
- "@olonjs/next": "^0.0.13",
766
- "@olonjs/react": "^0.2.1",
767
- "@olonjs/studio": "^0.2.1",
792
+ "@olonjs/core": "^2.0.3",
793
+ "@olonjs/next": "^0.0.15",
794
+ "@olonjs/react": "^0.2.3",
795
+ "@olonjs/sources": "^0.1.0",
796
+ "@olonjs/studio": "^0.2.3",
768
797
  "clsx": "^2.1.1",
769
798
  "lucide-react": "^0.474.0",
770
799
  "next": "^15.5.0",
@@ -779,6 +808,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
779
808
  "@types/node": "^22.13.1",
780
809
  "@types/react": "^19.0.0",
781
810
  "@types/react-dom": "^19.0.0",
811
+ "jsdom": "^26.1.0",
782
812
  "tailwindcss": "^4.0.0",
783
813
  "typescript": "^5.7.3",
784
814
  "vitest": "^3.0.0"
@@ -1011,10 +1041,16 @@ const SCRIPT = path.join(__dirname, 'webmcp-feature-check.mjs');
1011
1041
  const PKG = path.join(ROOT, 'package.json');
1012
1042
 
1013
1043
  describe('next verify:webmcp script', () => {
1014
- it('probes document.modelContextProtocol (the surface ensureWebMcpRuntime assigns)', () => {
1044
+ it('probes the native document.modelContext surface with polyfill fallback', () => {
1015
1045
  assert.ok(fs.existsSync(SCRIPT), `missing ${SCRIPT}`);
1016
1046
  const src = fs.readFileSync(SCRIPT, 'utf8');
1047
+ // Native Chrome surface: document.modelContext.getTools() / executeTool(toolObject, argsObject).
1048
+ assert.match(src, /document\.modelContext/);
1049
+ assert.match(src, /await native\.getTools\(\)/);
1050
+ assert.match(src, /native\.executeTool\(tool,/);
1051
+ // Polyfill fallback (browsers without native WebMCP): listTools / executeTool(name, json).
1017
1052
  assert.match(src, /document\.modelContextProtocol/);
1053
+ assert.match(src, /protocol\.executeTool\(/);
1018
1054
  // modelContextTesting is never assigned by the runtime — probing it is a false negative.
1019
1055
  assert.doesNotMatch(src, /modelContextTesting/);
1020
1056
  assert.doesNotMatch(src, /navigator\.modelContext/);
@@ -1031,302 +1067,351 @@ describe('next verify:webmcp script', () => {
1031
1067
  END_OF_FILE_CONTENT
1032
1068
  echo "Creating scripts/webmcp-feature-check.mjs..."
1033
1069
  cat << 'END_OF_FILE_CONTENT' > "scripts/webmcp-feature-check.mjs"
1034
- import fs from 'fs/promises';
1035
- import path from 'path';
1036
- import { fileURLToPath } from 'url';
1037
- import { createRequire } from 'module';
1038
-
1039
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
1040
- const rootDir = path.resolve(__dirname, '..');
1041
- const baseUrl = process.env.WEBMCP_BASE_URL ?? 'http://127.0.0.1:3000';
1042
- const UPDATE_TOOL_NAME = 'update-section';
1043
- const SAVE_TOOL_NAME = 'save';
1044
-
1045
- function pageFilePathFromSlug(slug) {
1046
- return path.resolve(rootDir, 'src', 'data', 'pages', `${slug}.json`);
1047
- }
1048
-
1049
- function adminUrlFromSlug(slug) {
1050
- return `${baseUrl}/admin${slug === 'home' ? '' : `/${slug}`}`;
1051
- }
1052
-
1053
- function isStringSchema(schema) {
1054
- if (!schema || typeof schema !== 'object') return false;
1055
- if (schema.type === 'string') return true;
1056
- if (Array.isArray(schema.anyOf)) {
1057
- return schema.anyOf.some((entry) => entry && typeof entry === 'object' && entry.type === 'string');
1058
- }
1059
- return false;
1060
- }
1061
-
1062
- function findTopLevelStringField(sectionSchema) {
1063
- const properties = sectionSchema?.properties;
1064
- if (!properties || typeof properties !== 'object') return null;
1065
- const preferred = ['title', 'sectionTitle', 'label', 'headline', 'name'];
1066
- for (const key of preferred) {
1067
- if (isStringSchema(properties[key])) return key;
1068
- }
1069
- for (const [key, value] of Object.entries(properties)) {
1070
- if (isStringSchema(value)) return key;
1071
- }
1072
- return null;
1073
- }
1074
-
1075
- async function loadPlaywright() {
1076
- const require = createRequire(import.meta.url);
1077
- try {
1078
- return require('playwright');
1079
- } catch (error) {
1080
- const message = error instanceof Error ? error.message : String(error);
1081
- throw new Error(
1082
- `Playwright is required for WebMCP verification. Install it before running this script. Original error: ${message}`
1083
- );
1084
- }
1085
- }
1086
-
1087
- async function readPageJson(slug) {
1088
- const pageFilePath = pageFilePathFromSlug(slug);
1089
- const raw = await fs.readFile(pageFilePath, 'utf8');
1090
- return { raw, json: JSON.parse(raw), pageFilePath };
1091
- }
1092
-
1093
- async function waitFor(predicate, timeoutMs, label) {
1094
- const startedAt = Date.now();
1095
- for (;;) {
1096
- const result = await predicate();
1097
- if (result) return result;
1098
- if (Date.now() - startedAt > timeoutMs) {
1099
- throw new Error(`Timed out while waiting for ${label}.`);
1100
- }
1101
- await new Promise((resolve) => setTimeout(resolve, 150));
1102
- }
1103
- }
1104
-
1105
- async function waitForFileFieldValue(slug, sectionId, fieldKey, expectedValue) {
1106
- await waitFor(async () => {
1107
- const { json } = await readPageJson(slug);
1108
- const section = Array.isArray(json.sections)
1109
- ? json.sections.find((item) => item?.id === sectionId)
1110
- : null;
1111
- return section?.data?.[fieldKey] === expectedValue;
1112
- }, 8_000, `file field "${fieldKey}" = "${expectedValue}"`);
1113
- }
1114
-
1115
- async function ensureResponseOk(response, label) {
1116
- if (!response.ok) {
1117
- const text = await response.text();
1118
- throw new Error(`${label} failed with ${response.status}: ${text}`);
1119
- }
1120
- return response;
1121
- }
1122
-
1123
- async function fetchJson(relativePath, label) {
1124
- const response = await ensureResponseOk(await fetch(`${baseUrl}${relativePath}`), label);
1125
- return response.json();
1126
- }
1127
-
1128
- async function selectTarget() {
1129
- const siteIndex = await fetchJson('/mcp-manifest.json', 'Manifest index request');
1130
- const requestedSlug = typeof process.env.WEBMCP_TARGET_SLUG === 'string' && process.env.WEBMCP_TARGET_SLUG.trim()
1131
- ? process.env.WEBMCP_TARGET_SLUG.trim()
1132
- : null;
1133
-
1134
- const candidatePages = requestedSlug
1135
- ? (siteIndex.pages ?? []).filter((page) => page?.slug === requestedSlug)
1136
- : (siteIndex.pages ?? []);
1137
-
1138
- for (const pageEntry of candidatePages) {
1139
- if (!pageEntry?.slug || !pageEntry?.manifestHref || !pageEntry?.contractHref) continue;
1140
- const pageManifest = await fetchJson(pageEntry.manifestHref, `Page manifest request for ${pageEntry.slug}`);
1141
- const pageContract = await fetchJson(pageEntry.contractHref, `Page contract request for ${pageEntry.slug}`);
1142
- const localInstances = Array.isArray(pageContract.sectionInstances)
1143
- ? pageContract.sectionInstances.filter((section) => section?.scope === 'local')
1144
- : [];
1145
- const tools = Array.isArray(pageManifest.tools) ? pageManifest.tools : [];
1146
- // Manifest tools are page-level (`update-section`, `save`); targets come from the
1147
- // contract's local section instances + their schemas.
1148
- const updateTool = tools.find((tool) => tool?.name === UPDATE_TOOL_NAME);
1149
- const saveTool = tools.find((tool) => tool?.name === SAVE_TOOL_NAME);
1150
- if (!updateTool || !saveTool) continue;
1151
-
1152
- for (const targetInstance of localInstances) {
1153
- const sectionType = targetInstance?.type;
1154
- if (!targetInstance?.id || typeof sectionType !== 'string') continue;
1155
- const targetFieldKey = findTopLevelStringField(pageContract.sectionSchemas?.[sectionType]);
1156
- if (!targetFieldKey) continue;
1157
- const pageState = await readPageJson(pageEntry.slug);
1158
- const section = Array.isArray(pageState.json.sections)
1159
- ? pageState.json.sections.find((item) => item?.id === targetInstance.id)
1160
- : null;
1161
- const originalValue = section?.data?.[targetFieldKey];
1162
- if (typeof originalValue !== 'string') continue;
1163
-
1164
- return {
1165
- slug: pageEntry.slug,
1166
- manifestHref: pageEntry.manifestHref,
1167
- contractHref: pageEntry.contractHref,
1168
- toolName: UPDATE_TOOL_NAME,
1169
- saveToolName: SAVE_TOOL_NAME,
1170
- sectionId: targetInstance.id,
1171
- sectionType,
1172
- fieldKey: targetFieldKey,
1173
- originalValue,
1174
- originalState: pageState,
1175
- };
1176
- }
1177
- }
1178
-
1179
- throw new Error(
1180
- requestedSlug
1181
- ? `No valid WebMCP verification target found for page "${requestedSlug}".`
1182
- : 'No valid WebMCP verification target found in manifest index.'
1183
- );
1184
- }
1185
-
1186
- async function main() {
1187
- const { chromium } = await loadPlaywright();
1188
- const target = await selectTarget();
1189
- const nextValue = `${target.originalValue} WebMCP ${Date.now()}`;
1190
- const browser = await chromium.launch({ headless: true });
1191
- const page = await browser.newPage();
1192
- const consoleEvents = [];
1193
- let mutationApplied = false;
1194
-
1195
- page.on('console', (message) => {
1196
- if (message.type() === 'error' || message.type() === 'warning') {
1197
- consoleEvents.push(`[console:${message.type()}] ${message.text()}`);
1198
- }
1199
- });
1200
- page.on('pageerror', (error) => {
1201
- consoleEvents.push(`[pageerror] ${error.message}`);
1202
- });
1203
-
1204
- /** update-section mutates the Studio draft only; save persists. Returns raw update result. */
1205
- const runUpdateThenSave = async (value) => {
1206
- const rawUpdate = await page.evaluate(
1207
- async ({ toolName, slug, sectionId, sectionType, fieldKey, value: nextValue }) => {
1208
- const runtime = document.modelContextProtocol;
1209
- if (!runtime?.executeTool) {
1210
- throw new Error('document.modelContextProtocol.executeTool is unavailable.');
1211
- }
1212
- return runtime.executeTool(
1213
- toolName,
1214
- JSON.stringify({ slug, sectionId, sectionType, fieldKey, value: nextValue })
1215
- );
1216
- },
1217
- {
1218
- toolName: target.toolName,
1219
- slug: target.slug,
1220
- sectionId: target.sectionId,
1221
- sectionType: target.sectionType,
1222
- fieldKey: target.fieldKey,
1223
- value,
1224
- }
1225
- );
1226
- const parsedUpdate = JSON.parse(rawUpdate);
1227
- if (parsedUpdate?.isError) {
1228
- throw new Error(`WebMCP ${target.toolName} returned an error: ${rawUpdate}`);
1229
- }
1230
- const rawSave = await page.evaluate(
1231
- async ({ saveToolName }) => document.modelContextProtocol.executeTool(saveToolName, '{}'),
1232
- { saveToolName: target.saveToolName }
1233
- );
1234
- const parsedSave = JSON.parse(rawSave);
1235
- if (parsedSave?.isError) {
1236
- throw new Error(`WebMCP ${target.saveToolName} returned an error: ${rawSave}`);
1237
- }
1238
- return rawUpdate;
1239
- };
1240
-
1241
- const restoreOriginal = async () => {
1242
- try {
1243
- await runUpdateThenSave(target.originalValue);
1244
- await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, target.originalValue);
1245
- } catch {
1246
- await fs.writeFile(target.originalState.pageFilePath, target.originalState.raw, 'utf8');
1247
- }
1248
- };
1249
-
1250
- try {
1251
- const pageManifest = await fetchJson(target.manifestHref, `Manifest request for ${target.slug}`);
1252
- if (!Array.isArray(pageManifest.tools) || !pageManifest.tools.some((tool) => tool?.name === target.toolName)) {
1253
- throw new Error(`Manifest does not expose ${target.toolName}.`);
1254
- }
1255
-
1256
- const pageContract = await fetchJson(target.contractHref, `Page contract request for ${target.slug}`);
1257
- if (!Array.isArray(pageContract.tools) || !pageContract.tools.some((tool) => tool?.name === target.toolName)) {
1258
- throw new Error(`Page contract does not expose ${target.toolName}.`);
1259
- }
1260
-
1261
- await page.goto(adminUrlFromSlug(target.slug), { waitUntil: 'networkidle' });
1262
-
1263
- try {
1264
- await page.waitForFunction(
1265
- ({ manifestHref, contractHref }) => {
1266
- const manifestLink = document.head.querySelector('link[rel="mcp-manifest"]');
1267
- const contractLink = document.head.querySelector('link[rel="olon-contract"]');
1268
- return manifestLink?.getAttribute('href') === manifestHref
1269
- && contractLink?.getAttribute('href') === contractHref;
1270
- },
1271
- { manifestHref: target.manifestHref, contractHref: target.contractHref },
1272
- { timeout: 10_000 }
1273
- );
1274
- } catch (error) {
1275
- const diagnostics = await page.evaluate(() => ({
1276
- head: document.head.innerHTML,
1277
- bodyText: document.body.innerText,
1278
- }));
1279
- throw new Error(
1280
- [
1281
- error instanceof Error ? error.message : String(error),
1282
- `head=${diagnostics.head}`,
1283
- `body=${diagnostics.bodyText}`,
1284
- ...consoleEvents,
1285
- ].join('\n')
1286
- );
1287
- }
1288
-
1289
- const toolNames = await page.evaluate(() => {
1290
- const runtime = document.modelContextProtocol;
1291
- return runtime?.listTools?.().map((tool) => tool.name) ?? [];
1292
- });
1293
- for (const required of [target.toolName, target.saveToolName]) {
1294
- if (!toolNames.includes(required)) {
1295
- throw new Error(`Runtime did not register ${required}. Found: ${toolNames.join(', ')}`);
1296
- }
1297
- }
1298
-
1299
- mutationApplied = true;
1300
- await runUpdateThenSave(nextValue);
1301
- await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, nextValue);
1302
- await page.frameLocator('iframe').getByText(nextValue, { exact: true }).waitFor({ state: 'attached' });
1303
-
1304
- console.log(
1305
- JSON.stringify({
1306
- ok: true,
1307
- slug: target.slug,
1308
- manifestHref: target.manifestHref,
1309
- contractHref: target.contractHref,
1310
- toolName: target.toolName,
1311
- saveToolName: target.saveToolName,
1312
- sectionId: target.sectionId,
1313
- sectionType: target.sectionType,
1314
- fieldKey: target.fieldKey,
1315
- toolNames,
1316
- })
1317
- );
1318
- } finally {
1319
- if (mutationApplied) {
1320
- await restoreOriginal();
1321
- }
1322
- await browser.close();
1323
- }
1324
- }
1325
-
1326
- main().catch((error) => {
1327
- console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1328
- process.exit(1);
1329
- });
1070
+ import fs from 'fs/promises';
1071
+ import path from 'path';
1072
+ import { fileURLToPath } from 'url';
1073
+ import { createRequire } from 'module';
1074
+
1075
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1076
+ const rootDir = path.resolve(__dirname, '..');
1077
+ const baseUrl = process.env.WEBMCP_BASE_URL ?? 'http://127.0.0.1:3000';
1078
+ const UPDATE_TOOL_NAME = 'update-section';
1079
+ const SAVE_TOOL_NAME = 'save';
1080
+
1081
+ function pageFilePathFromSlug(slug) {
1082
+ return path.resolve(rootDir, 'src', 'data', 'pages', `${slug}.json`);
1083
+ }
1084
+
1085
+ function adminUrlFromSlug(slug) {
1086
+ return `${baseUrl}/admin${slug === 'home' ? '' : `/${slug}`}`;
1087
+ }
1088
+
1089
+ function isStringSchema(schema) {
1090
+ if (!schema || typeof schema !== 'object') return false;
1091
+ if (schema.type === 'string') return true;
1092
+ if (Array.isArray(schema.anyOf)) {
1093
+ return schema.anyOf.some((entry) => entry && typeof entry === 'object' && entry.type === 'string');
1094
+ }
1095
+ return false;
1096
+ }
1097
+
1098
+ function findTopLevelStringField(sectionSchema) {
1099
+ const properties = sectionSchema?.properties;
1100
+ if (!properties || typeof properties !== 'object') return null;
1101
+ const preferred = ['title', 'sectionTitle', 'label', 'headline', 'name'];
1102
+ for (const key of preferred) {
1103
+ if (isStringSchema(properties[key])) return key;
1104
+ }
1105
+ for (const [key, value] of Object.entries(properties)) {
1106
+ if (isStringSchema(value)) return key;
1107
+ }
1108
+ return null;
1109
+ }
1110
+
1111
+ async function loadPlaywright() {
1112
+ const require = createRequire(import.meta.url);
1113
+ try {
1114
+ return require('playwright');
1115
+ } catch (error) {
1116
+ const message = error instanceof Error ? error.message : String(error);
1117
+ throw new Error(
1118
+ `Playwright is required for WebMCP verification. Install it before running this script. Original error: ${message}`
1119
+ );
1120
+ }
1121
+ }
1122
+
1123
+ async function readPageJson(slug) {
1124
+ const pageFilePath = pageFilePathFromSlug(slug);
1125
+ const raw = await fs.readFile(pageFilePath, 'utf8');
1126
+ return { raw, json: JSON.parse(raw), pageFilePath };
1127
+ }
1128
+
1129
+ async function waitFor(predicate, timeoutMs, label) {
1130
+ const startedAt = Date.now();
1131
+ for (;;) {
1132
+ const result = await predicate();
1133
+ if (result) return result;
1134
+ if (Date.now() - startedAt > timeoutMs) {
1135
+ throw new Error(`Timed out while waiting for ${label}.`);
1136
+ }
1137
+ await new Promise((resolve) => setTimeout(resolve, 150));
1138
+ }
1139
+ }
1140
+
1141
+ async function waitForFileFieldValue(slug, sectionId, fieldKey, expectedValue) {
1142
+ await waitFor(async () => {
1143
+ const { json } = await readPageJson(slug);
1144
+ const section = Array.isArray(json.sections)
1145
+ ? json.sections.find((item) => item?.id === sectionId)
1146
+ : null;
1147
+ return section?.data?.[fieldKey] === expectedValue;
1148
+ }, 8_000, `file field "${fieldKey}" = "${expectedValue}"`);
1149
+ }
1150
+
1151
+ async function ensureResponseOk(response, label) {
1152
+ if (!response.ok) {
1153
+ const text = await response.text();
1154
+ throw new Error(`${label} failed with ${response.status}: ${text}`);
1155
+ }
1156
+ return response;
1157
+ }
1158
+
1159
+ async function fetchJson(relativePath, label) {
1160
+ const response = await ensureResponseOk(await fetch(`${baseUrl}${relativePath}`), label);
1161
+ return response.json();
1162
+ }
1163
+
1164
+ async function selectTarget() {
1165
+ const siteIndex = await fetchJson('/mcp-manifest.json', 'Manifest index request');
1166
+ const requestedSlug = typeof process.env.WEBMCP_TARGET_SLUG === 'string' && process.env.WEBMCP_TARGET_SLUG.trim()
1167
+ ? process.env.WEBMCP_TARGET_SLUG.trim()
1168
+ : null;
1169
+
1170
+ const candidatePages = requestedSlug
1171
+ ? (siteIndex.pages ?? []).filter((page) => page?.slug === requestedSlug)
1172
+ : (siteIndex.pages ?? []);
1173
+
1174
+ for (const pageEntry of candidatePages) {
1175
+ if (!pageEntry?.slug || !pageEntry?.manifestHref || !pageEntry?.contractHref) continue;
1176
+ const pageManifest = await fetchJson(pageEntry.manifestHref, `Page manifest request for ${pageEntry.slug}`);
1177
+ const pageContract = await fetchJson(pageEntry.contractHref, `Page contract request for ${pageEntry.slug}`);
1178
+ const localInstances = Array.isArray(pageContract.sectionInstances)
1179
+ ? pageContract.sectionInstances.filter((section) => section?.scope === 'local')
1180
+ : [];
1181
+ const tools = Array.isArray(pageManifest.tools) ? pageManifest.tools : [];
1182
+ // Manifest tools are page-level (`update-section`, `save`); targets come from the
1183
+ // contract's local section instances + their schemas.
1184
+ const updateTool = tools.find((tool) => tool?.name === UPDATE_TOOL_NAME);
1185
+ const saveTool = tools.find((tool) => tool?.name === SAVE_TOOL_NAME);
1186
+ if (!updateTool || !saveTool) continue;
1187
+
1188
+ for (const targetInstance of localInstances) {
1189
+ const sectionType = targetInstance?.type;
1190
+ if (!targetInstance?.id || typeof sectionType !== 'string') continue;
1191
+ const targetFieldKey = findTopLevelStringField(pageContract.sectionSchemas?.[sectionType]);
1192
+ if (!targetFieldKey) continue;
1193
+ const pageState = await readPageJson(pageEntry.slug);
1194
+ const section = Array.isArray(pageState.json.sections)
1195
+ ? pageState.json.sections.find((item) => item?.id === targetInstance.id)
1196
+ : null;
1197
+ const originalValue = section?.data?.[targetFieldKey];
1198
+ if (typeof originalValue !== 'string') continue;
1199
+
1200
+ return {
1201
+ slug: pageEntry.slug,
1202
+ manifestHref: pageEntry.manifestHref,
1203
+ contractHref: pageEntry.contractHref,
1204
+ toolName: UPDATE_TOOL_NAME,
1205
+ saveToolName: SAVE_TOOL_NAME,
1206
+ sectionId: targetInstance.id,
1207
+ sectionType,
1208
+ fieldKey: targetFieldKey,
1209
+ originalValue,
1210
+ originalState: pageState,
1211
+ };
1212
+ }
1213
+ }
1214
+
1215
+ throw new Error(
1216
+ requestedSlug
1217
+ ? `No valid WebMCP verification target found for page "${requestedSlug}".`
1218
+ : 'No valid WebMCP verification target found in manifest index.'
1219
+ );
1220
+ }
1221
+
1222
+ async function main() {
1223
+ const { chromium } = await loadPlaywright();
1224
+ const target = await selectTarget();
1225
+ const nextValue = `${target.originalValue} WebMCP ${Date.now()}`;
1226
+ const browser = await chromium.launch({ headless: true });
1227
+ const page = await browser.newPage();
1228
+ const consoleEvents = [];
1229
+ let mutationApplied = false;
1230
+
1231
+ page.on('console', (message) => {
1232
+ if (message.type() === 'error' || message.type() === 'warning') {
1233
+ consoleEvents.push(`[console:${message.type()}] ${message.text()}`);
1234
+ }
1235
+ });
1236
+ page.on('pageerror', (error) => {
1237
+ consoleEvents.push(`[pageerror] ${error.message}`);
1238
+ });
1239
+
1240
+ /** update-section mutates the Studio draft only; save persists. Returns raw update result. */
1241
+ const runUpdateThenSave = async (value) => {
1242
+ const rawUpdate = await page.evaluate(
1243
+ async ({ toolName, slug, sectionId, sectionType, fieldKey, value: nextValue }) => {
1244
+ const args = { slug, sectionId, sectionType, fieldKey, value: nextValue };
1245
+ const native = document.modelContext;
1246
+ const protocol = document.modelContextProtocol;
1247
+ // Native Chrome WebMCP: executeTool(toolObject, args) — the tool object
1248
+ // comes from getTools(). Args format: plain object since Chrome 155;
1249
+ // before that the native expects a JSON string (verified on Chrome 153).
1250
+ if (native && typeof native.executeTool === 'function') {
1251
+ const tools = await native.getTools();
1252
+ const tool = tools.find((entry) => entry.name === toolName);
1253
+ if (!tool) {
1254
+ throw new Error(`Tool "${toolName}" not found in native getTools().`);
1255
+ }
1256
+ const chromeMajor = Number((navigator.userAgent.match(/Chrome\/(\d+)/) ?? [])[1] ?? 0);
1257
+ const nativeArgs = chromeMajor >= 155 ? args : JSON.stringify(args);
1258
+ const result = await native.executeTool(tool, nativeArgs);
1259
+ if (result === null) {
1260
+ throw new Error(`Tool "${toolName}" returned null (navigation).`);
1261
+ }
1262
+ return typeof result === 'string' ? result : JSON.stringify(result);
1263
+ }
1264
+ if (protocol && typeof protocol.executeTool === 'function') {
1265
+ return protocol.executeTool(toolName, JSON.stringify(args));
1266
+ }
1267
+ throw new Error(
1268
+ 'WebMCP runtime unavailable: no document.modelContext (native) and no document.modelContextProtocol (polyfill).',
1269
+ );
1270
+ },
1271
+ {
1272
+ toolName: target.toolName,
1273
+ slug: target.slug,
1274
+ sectionId: target.sectionId,
1275
+ sectionType: target.sectionType,
1276
+ fieldKey: target.fieldKey,
1277
+ value,
1278
+ }
1279
+ );
1280
+ const parsedUpdate = JSON.parse(rawUpdate);
1281
+ if (parsedUpdate?.isError) {
1282
+ throw new Error(`WebMCP ${target.toolName} returned an error: ${rawUpdate}`);
1283
+ }
1284
+ const rawSave = await page.evaluate(
1285
+ async ({ saveToolName }) => {
1286
+ const native = document.modelContext;
1287
+ const protocol = document.modelContextProtocol;
1288
+ if (native && typeof native.executeTool === 'function') {
1289
+ const tools = await native.getTools();
1290
+ const tool = tools.find((entry) => entry.name === saveToolName);
1291
+ if (!tool) {
1292
+ throw new Error(`Tool "${saveToolName}" not found in native getTools().`);
1293
+ }
1294
+ const chromeMajor = Number((navigator.userAgent.match(/Chrome\/(\d+)/) ?? [])[1] ?? 0);
1295
+ const nativeArgs = chromeMajor >= 155 ? {} : '{}';
1296
+ const result = await native.executeTool(tool, nativeArgs);
1297
+ if (result === null) {
1298
+ throw new Error(`Tool "${saveToolName}" returned null (navigation).`);
1299
+ }
1300
+ return typeof result === 'string' ? result : JSON.stringify(result);
1301
+ }
1302
+ if (protocol && typeof protocol.executeTool === 'function') {
1303
+ return protocol.executeTool(saveToolName, '{}');
1304
+ }
1305
+ throw new Error(
1306
+ 'WebMCP runtime unavailable: no document.modelContext (native) and no document.modelContextProtocol (polyfill).',
1307
+ );
1308
+ },
1309
+ { saveToolName: target.saveToolName }
1310
+ );
1311
+ const parsedSave = JSON.parse(rawSave);
1312
+ if (parsedSave?.isError) {
1313
+ throw new Error(`WebMCP ${target.saveToolName} returned an error: ${rawSave}`);
1314
+ }
1315
+ return rawUpdate;
1316
+ };
1317
+
1318
+ const restoreOriginal = async () => {
1319
+ try {
1320
+ await runUpdateThenSave(target.originalValue);
1321
+ await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, target.originalValue);
1322
+ } catch {
1323
+ await fs.writeFile(target.originalState.pageFilePath, target.originalState.raw, 'utf8');
1324
+ }
1325
+ };
1326
+
1327
+ try {
1328
+ const pageManifest = await fetchJson(target.manifestHref, `Manifest request for ${target.slug}`);
1329
+ if (!Array.isArray(pageManifest.tools) || !pageManifest.tools.some((tool) => tool?.name === target.toolName)) {
1330
+ throw new Error(`Manifest does not expose ${target.toolName}.`);
1331
+ }
1332
+
1333
+ const pageContract = await fetchJson(target.contractHref, `Page contract request for ${target.slug}`);
1334
+ if (!Array.isArray(pageContract.tools) || !pageContract.tools.some((tool) => tool?.name === target.toolName)) {
1335
+ throw new Error(`Page contract does not expose ${target.toolName}.`);
1336
+ }
1337
+
1338
+ await page.goto(adminUrlFromSlug(target.slug), { waitUntil: 'networkidle' });
1339
+
1340
+ try {
1341
+ await page.waitForFunction(
1342
+ ({ manifestHref, contractHref }) => {
1343
+ const manifestLink = document.head.querySelector('link[rel="mcp-manifest"]');
1344
+ const contractLink = document.head.querySelector('link[rel="olon-contract"]');
1345
+ return manifestLink?.getAttribute('href') === manifestHref
1346
+ && contractLink?.getAttribute('href') === contractHref;
1347
+ },
1348
+ { manifestHref: target.manifestHref, contractHref: target.contractHref },
1349
+ { timeout: 10_000 }
1350
+ );
1351
+ } catch (error) {
1352
+ const diagnostics = await page.evaluate(() => ({
1353
+ head: document.head.innerHTML,
1354
+ bodyText: document.body.innerText,
1355
+ }));
1356
+ throw new Error(
1357
+ [
1358
+ error instanceof Error ? error.message : String(error),
1359
+ `head=${diagnostics.head}`,
1360
+ `body=${diagnostics.bodyText}`,
1361
+ ...consoleEvents,
1362
+ ].join('\n')
1363
+ );
1364
+ }
1365
+
1366
+ const toolNames = await page.evaluate(async () => {
1367
+ const native = document.modelContext;
1368
+ const protocol = document.modelContextProtocol;
1369
+ if (native && typeof native.getTools === 'function') {
1370
+ const tools = await native.getTools();
1371
+ return tools.map((tool) => tool.name);
1372
+ }
1373
+ if (protocol && typeof protocol.listTools === 'function') {
1374
+ return protocol.listTools().map((tool) => tool.name);
1375
+ }
1376
+ return [];
1377
+ });
1378
+ for (const required of [target.toolName, target.saveToolName]) {
1379
+ if (!toolNames.includes(required)) {
1380
+ throw new Error(`Runtime did not register ${required}. Found: ${toolNames.join(', ')}`);
1381
+ }
1382
+ }
1383
+
1384
+ mutationApplied = true;
1385
+ await runUpdateThenSave(nextValue);
1386
+ await waitForFileFieldValue(target.slug, target.sectionId, target.fieldKey, nextValue);
1387
+ await page.frameLocator('iframe').getByText(nextValue, { exact: true }).waitFor({ state: 'attached' });
1388
+
1389
+ console.log(
1390
+ JSON.stringify({
1391
+ ok: true,
1392
+ slug: target.slug,
1393
+ manifestHref: target.manifestHref,
1394
+ contractHref: target.contractHref,
1395
+ toolName: target.toolName,
1396
+ saveToolName: target.saveToolName,
1397
+ sectionId: target.sectionId,
1398
+ sectionType: target.sectionType,
1399
+ fieldKey: target.fieldKey,
1400
+ toolNames,
1401
+ })
1402
+ );
1403
+ } finally {
1404
+ if (mutationApplied) {
1405
+ await restoreOriginal();
1406
+ }
1407
+ await browser.close();
1408
+ }
1409
+ }
1410
+
1411
+ main().catch((error) => {
1412
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1413
+ process.exit(1);
1414
+ });
1330
1415
 
1331
1416
  END_OF_FILE_CONTENT
1332
1417
  mkdir -p "src"
@@ -1396,6 +1481,43 @@ import { LibroSchema, LibriCollectionSchema } from './schema';
1396
1481
  export type Libro = z.infer<typeof LibroSchema>;
1397
1482
  export type LibriCollection = z.infer<typeof LibriCollectionSchema>;
1398
1483
 
1484
+ END_OF_FILE_CONTENT
1485
+ mkdir -p "src/collections/prodotti"
1486
+ echo "Creating src/collections/prodotti/index.ts..."
1487
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/prodotti/index.ts"
1488
+ export { ProdottoSchema, ProdottiCollectionSchema } from './schema';
1489
+ export type { Prodotto, ProdottiCollection } from './types';
1490
+
1491
+ END_OF_FILE_CONTENT
1492
+ echo "Creating src/collections/prodotti/schema.ts..."
1493
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/prodotti/schema.ts"
1494
+ import { z } from 'zod';
1495
+ import { BaseCollectionItem } from '@olonjs/core';
1496
+
1497
+ /**
1498
+ * Demo collection whose source of truth is EXTERNAL (see src/sources/catalogo.ts).
1499
+ * Same Zod item shape feeds the human list rendering (ui:* descriptors) and the
1500
+ * agent contracts (JSON Schema emitted from this schema).
1501
+ */
1502
+ export const ProdottoSchema = BaseCollectionItem.extend({
1503
+ title: z.string().describe('ui:text'),
1504
+ price: z.number().describe('ui:number'),
1505
+ currency: z.string().default('EUR').describe('ui:text'),
1506
+ description: z.string().optional().describe('ui:textarea'),
1507
+ image: z.string().optional().describe('ui:text'),
1508
+ });
1509
+
1510
+ export const ProdottiCollectionSchema = z.record(z.string(), ProdottoSchema);
1511
+
1512
+ END_OF_FILE_CONTENT
1513
+ echo "Creating src/collections/prodotti/types.ts..."
1514
+ cat << 'END_OF_FILE_CONTENT' > "src/collections/prodotti/types.ts"
1515
+ import { z } from 'zod';
1516
+ import { ProdottoSchema, ProdottiCollectionSchema } from './schema';
1517
+
1518
+ export type Prodotto = z.infer<typeof ProdottoSchema>;
1519
+ export type ProdottiCollection = z.infer<typeof ProdottiCollectionSchema>;
1520
+
1399
1521
  END_OF_FILE_CONTENT
1400
1522
  mkdir -p "src/components"
1401
1523
  mkdir -p "src/components/admin"
@@ -1420,6 +1542,7 @@ import { ComponentRegistry } from '@/lib/ComponentRegistry';
1420
1542
  import { iconMap } from '@/lib/IconResolver';
1421
1543
  import { SECTION_SCHEMAS } from '@/lib/schemas';
1422
1544
  import { buildEnabledWebMcpConfig } from '@/lib/webmcp/buildEnabledWebMcpConfig';
1545
+ import { adminToolExecutors, declaredAdminTools } from '@/lib/webmcp/toolRegistry';
1423
1546
 
1424
1547
  export type AdminStudioClientProps = {
1425
1548
  tenantId?: string;
@@ -1507,7 +1630,11 @@ export function AdminStudioClient({
1507
1630
  themeCss: { tenant: '' },
1508
1631
  iconRegistry: iconMap,
1509
1632
  addSection: addSectionConfig,
1510
- webmcp: buildEnabledWebMcpConfig(),
1633
+ webmcp: {
1634
+ ...buildEnabledWebMcpConfig(),
1635
+ tools: declaredAdminTools,
1636
+ toolExecutors: adminToolExecutors,
1637
+ },
1511
1638
  persistence: {
1512
1639
  saveToFile,
1513
1640
  ...(coldSave ? { coldSave } : {}),
@@ -2535,6 +2662,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/index.ts"
2535
2662
  export { FormDemoView } from './View';
2536
2663
  export { FormDemoClient } from './FormDemoClient';
2537
2664
  export { FormDemoSchema, FormDemoSettingsSchema, FormDemoSubmissionSchema } from './schema';
2665
+ export { webmcpTools } from './webmcpTools';
2538
2666
  export type { FormDemoData, FormDemoSettings } from './types';
2539
2667
 
2540
2668
  END_OF_FILE_CONTENT
@@ -2562,6 +2690,173 @@ export const FormDemoSubmissionSchema = z.object({
2562
2690
  message: z.string().min(1).describe('Free-form message body'),
2563
2691
  });
2564
2692
 
2693
+ END_OF_FILE_CONTENT
2694
+ echo "Creating src/components/form-demo/submitFormLead.test.ts..."
2695
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/submitFormLead.test.ts"
2696
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2697
+
2698
+ const CLOUD_URL = 'https://cloud.example.com';
2699
+ const API_KEY = 'secret-key';
2700
+
2701
+ async function loadSubmitFormLead() {
2702
+ vi.resetModules();
2703
+ vi.stubEnv('NEXT_PUBLIC_OLONJS_CLOUD_URL', CLOUD_URL);
2704
+ vi.stubEnv('NEXT_PUBLIC_OLONJS_API_KEY', API_KEY);
2705
+ return await import('./submitFormLead');
2706
+ }
2707
+
2708
+ beforeEach(() => {
2709
+ vi.stubGlobal('fetch', vi.fn());
2710
+ });
2711
+
2712
+ afterEach(() => {
2713
+ vi.unstubAllEnvs();
2714
+ vi.unstubAllGlobals();
2715
+ });
2716
+
2717
+ function mockFetchOk() {
2718
+ const fetchMock = vi.mocked(fetch);
2719
+ fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
2720
+ return fetchMock;
2721
+ }
2722
+
2723
+ describe('submitFormLead', () => {
2724
+ it('POSTs to {cloudUrl}/form/send with parity headers and full payload', async () => {
2725
+ const { submitFormLead } = await loadSubmitFormLead();
2726
+ const fetchMock = mockFetchOk();
2727
+
2728
+ const ctx = {
2729
+ recipientEmail: 'lead@example.com',
2730
+ sectionId: 'form-demo-1',
2731
+ pageSlug: 'contatti',
2732
+ };
2733
+ const args = { name: 'Ada', email: 'ada@example.com', message: 'Ciao' };
2734
+
2735
+ await expect(submitFormLead(args, ctx)).resolves.toEqual({ ok: true });
2736
+
2737
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
2738
+ expect(url).toBe(`${CLOUD_URL}/form/send`);
2739
+ const headers = init.headers as Record<string, string>;
2740
+ expect(headers.Authorization).toBe(`Bearer ${API_KEY}`);
2741
+ expect(headers['Content-Type']).toBe('application/json');
2742
+ expect(headers['Idempotency-Key']).toMatch(/^form-form-demo-1-\d+$/);
2743
+ const payload = JSON.parse(init.body as string) as Record<string, unknown>;
2744
+ expect(payload).toMatchObject({
2745
+ name: 'Ada',
2746
+ email: 'ada@example.com',
2747
+ message: 'Ciao',
2748
+ recipientEmail: 'lead@example.com',
2749
+ page: 'contatti',
2750
+ section: 'form-demo-1',
2751
+ source: 'olon-webmcp',
2752
+ });
2753
+ expect(typeof payload.submittedAt).toBe('string');
2754
+ });
2755
+
2756
+ it('uses the dev-declared path, normalizing slashes', async () => {
2757
+ const { submitFormLead } = await loadSubmitFormLead();
2758
+ const fetchMock = mockFetchOk();
2759
+
2760
+ await submitFormLead(
2761
+ { name: 'A', email: 'a@b.c', message: 'm' },
2762
+ { recipientEmail: '', sectionId: 's1', pageSlug: 'x', path: '/lead/submit/' },
2763
+ );
2764
+
2765
+ expect((fetchMock.mock.calls[0] as [string])[0]).toBe(`${CLOUD_URL}/lead/submit`);
2766
+ });
2767
+
2768
+ it('throws a readable error on a non-ok response', async () => {
2769
+ const { submitFormLead } = await loadSubmitFormLead();
2770
+ vi.mocked(fetch).mockResolvedValue(
2771
+ new Response(JSON.stringify({ error: 'bad gateway' }), { status: 502 }),
2772
+ );
2773
+
2774
+ await expect(
2775
+ submitFormLead(
2776
+ { name: 'A', email: 'a@b.c', message: 'm' },
2777
+ { recipientEmail: '', sectionId: 's1', pageSlug: 'x' },
2778
+ ),
2779
+ ).rejects.toThrow(/bad gateway/);
2780
+ });
2781
+
2782
+ it('throws an explicit error when the cloud env is not configured', async () => {
2783
+ vi.resetModules();
2784
+ vi.stubEnv('NEXT_PUBLIC_OLONJS_CLOUD_URL', '');
2785
+ vi.stubEnv('NEXT_PUBLIC_OLONJS_API_KEY', '');
2786
+ const { submitFormLead } = await import('./submitFormLead');
2787
+
2788
+ await expect(
2789
+ submitFormLead(
2790
+ { name: 'A', email: 'a@b.c', message: 'm' },
2791
+ { recipientEmail: '', sectionId: 's1', pageSlug: 'x' },
2792
+ ),
2793
+ ).rejects.toThrow(/Cloud env not configured/);
2794
+ });
2795
+ });
2796
+
2797
+ END_OF_FILE_CONTENT
2798
+ echo "Creating src/components/form-demo/submitFormLead.ts..."
2799
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/submitFormLead.ts"
2800
+ import { cloudPolicy } from '@/lib/env/tenantEnv';
2801
+
2802
+ export interface SubmitFormArgs {
2803
+ name: string;
2804
+ email: string;
2805
+ message: string;
2806
+ }
2807
+
2808
+ export interface SubmitFormContext {
2809
+ recipientEmail: string;
2810
+ sectionId: string;
2811
+ pageSlug: string;
2812
+ /** The dev-declared endpoint path (the variable piece); default 'form/send'. */
2813
+ path?: string;
2814
+ }
2815
+
2816
+ /**
2817
+ * Shared submit executor for form sections (spec 03-webmcp-tool-submit-plugin).
2818
+ *
2819
+ * Base URL always comes from the cloud env chain (`tenantEnv`/`cloudPolicy`);
2820
+ * the dev declares only the variable path. Header parity with tenant-alpha's
2821
+ * `useOlonForms` (Bearer + Idempotency-Key).
2822
+ */
2823
+ export async function submitFormLead(
2824
+ args: SubmitFormArgs,
2825
+ ctx: SubmitFormContext,
2826
+ ): Promise<{ ok: true }> {
2827
+ const base = (cloudPolicy.apiUrl ?? '').replace(/\/$/, '');
2828
+ if (!base || !cloudPolicy.apiKey) {
2829
+ throw new Error('Cloud env not configured (NEXT_PUBLIC_OLONJS_*).');
2830
+ }
2831
+
2832
+ const path = (ctx.path ?? 'form/send').replace(/^\/+|\/+$/g, '');
2833
+ const endpoint = `${base}/${path}`;
2834
+
2835
+ const response = await fetch(endpoint, {
2836
+ method: 'POST',
2837
+ headers: {
2838
+ Authorization: `Bearer ${cloudPolicy.apiKey}`,
2839
+ 'Content-Type': 'application/json',
2840
+ 'Idempotency-Key': `form-${ctx.sectionId}-${Date.now()}`,
2841
+ },
2842
+ body: JSON.stringify({
2843
+ ...args,
2844
+ recipientEmail: ctx.recipientEmail,
2845
+ page: ctx.pageSlug,
2846
+ section: ctx.sectionId,
2847
+ source: 'olon-webmcp',
2848
+ submittedAt: new Date().toISOString(),
2849
+ }),
2850
+ });
2851
+
2852
+ const body = (await response.json().catch(() => ({}))) as { error?: string };
2853
+ if (!response.ok) {
2854
+ throw new Error(body.error ?? `Submit failed (${response.status})`);
2855
+ }
2856
+
2857
+ return { ok: true };
2858
+ }
2859
+
2565
2860
  END_OF_FILE_CONTENT
2566
2861
  echo "Creating src/components/form-demo/types.ts..."
2567
2862
  cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/types.ts"
@@ -2571,6 +2866,27 @@ import { FormDemoSchema, FormDemoSettingsSchema } from './schema';
2571
2866
  export type FormDemoData = z.infer<typeof FormDemoSchema>;
2572
2867
  export type FormDemoSettings = z.infer<typeof FormDemoSettingsSchema>;
2573
2868
 
2869
+ END_OF_FILE_CONTENT
2870
+ echo "Creating src/components/form-demo/webmcpTools.ts..."
2871
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/webmcpTools.ts"
2872
+ import type { WebMcpToolDef } from '@olonjs/core';
2873
+ import { FormDemoSubmissionSchema } from './schema';
2874
+
2875
+ /**
2876
+ * Public WebMCP tools declared by the form-demo capsule (spec 03).
2877
+ * Serializable defs only — the executor is registered client-side in
2878
+ * `src/lib/webmcp/submitFormTool.ts` (functions cannot cross the RSC boundary).
2879
+ */
2880
+ export const webmcpTools: readonly WebMcpToolDef[] = [
2881
+ {
2882
+ name: 'submit-form',
2883
+ description: 'Submit the contact form on this page (name, email, message).',
2884
+ surface: 'public',
2885
+ inputSchema: FormDemoSubmissionSchema.toJSONSchema() as Record<string, unknown>,
2886
+ annotations: { consequentialHint: true },
2887
+ },
2888
+ ];
2889
+
2574
2890
  END_OF_FILE_CONTENT
2575
2891
  mkdir -p "src/components/header"
2576
2892
  echo "Creating src/components/header/View.tsx..."
@@ -2661,6 +2977,183 @@ import { HeaderSchema, HeaderSettingsSchema } from './schema';
2661
2977
  export type HeaderData = z.infer<typeof HeaderSchema>;
2662
2978
  export type HeaderSettings = z.infer<typeof HeaderSettingsSchema>;
2663
2979
 
2980
+ END_OF_FILE_CONTENT
2981
+ mkdir -p "src/components/prodotti-list"
2982
+ echo "Creating src/components/prodotti-list/View.tsx..."
2983
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/View.tsx"
2984
+ import type { Prodotto } from '@/collections/prodotti';
2985
+ import type { ProdottiListData, ProdottiListSettings } from './types';
2986
+
2987
+ type ProdottiListViewProps = {
2988
+ data: ProdottiListData;
2989
+ settings?: ProdottiListSettings;
2990
+ };
2991
+
2992
+ function formatPrice(prodotto: Prodotto): string {
2993
+ const amount = (prodotto.price / 100).toFixed(2);
2994
+ return `${amount} ${prodotto.currency ?? 'EUR'}`;
2995
+ }
2996
+
2997
+ function toProdotti(items: ProdottiListData['items']): Prodotto[] {
2998
+ return Object.values(items ?? {}).sort((a, b) => a.title.localeCompare(b.title));
2999
+ }
3000
+
3001
+ /**
3002
+ * RSC-safe catalog list — data comes from a PROJECTED external source
3003
+ * (src/sources/catalogo.ts). The View itself never fetches (CIP).
3004
+ */
3005
+ export function ProdottiListView({ data }: ProdottiListViewProps) {
3006
+ const prodotti = toProdotti(data.items);
3007
+
3008
+ return (
3009
+ <section className="bg-background px-6 py-16 text-foreground">
3010
+ <div className="mx-auto w-full max-w-5xl">
3011
+ {data.eyebrow ? (
3012
+ <p className="text-sm font-medium uppercase tracking-[0.18em] text-muted-foreground">
3013
+ {data.eyebrow}
3014
+ </p>
3015
+ ) : null}
3016
+ <h1 data-jp-field="title" className="mt-2 text-3xl font-semibold">
3017
+ {data.title}
3018
+ </h1>
3019
+ {data.description ? (
3020
+ <p data-jp-field="description" className="mt-3 max-w-2xl text-muted-foreground">
3021
+ {data.description}
3022
+ </p>
3023
+ ) : null}
3024
+
3025
+ <ul className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
3026
+ {prodotti.map((prodotto) => (
3027
+ <li
3028
+ key={prodotto.id}
3029
+ data-jp-item-id={prodotto.id}
3030
+ data-jp-item-field="items"
3031
+ className="rounded-2xl border border-border bg-card p-6 shadow-sm"
3032
+ >
3033
+ <a
3034
+ href={`/prodotti/${prodotto.id}`}
3035
+ className="text-lg font-medium hover:underline"
3036
+ >
3037
+ {prodotto.title}
3038
+ </a>
3039
+ <p className="mt-2 text-sm text-muted-foreground">{formatPrice(prodotto)}</p>
3040
+ {prodotto.description ? (
3041
+ <p className="mt-3 text-sm text-muted-foreground">{prodotto.description}</p>
3042
+ ) : null}
3043
+ </li>
3044
+ ))}
3045
+ </ul>
3046
+
3047
+ {prodotti.length === 0 ? (
3048
+ <p className="mt-10 text-muted-foreground">No products available.</p>
3049
+ ) : null}
3050
+ </div>
3051
+ </section>
3052
+ );
3053
+ }
3054
+
3055
+ END_OF_FILE_CONTENT
3056
+ echo "Creating src/components/prodotti-list/index.ts..."
3057
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/index.ts"
3058
+ export { ProdottiListView } from './View';
3059
+ export { ProdottiListSchema, ProdottiListSettingsSchema } from './schema';
3060
+ export type { ProdottiListData, ProdottiListSettings } from './types';
3061
+
3062
+ END_OF_FILE_CONTENT
3063
+ echo "Creating src/components/prodotti-list/schema.ts..."
3064
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/schema.ts"
3065
+ import { z } from 'zod';
3066
+ import { BaseSectionData } from '@olonjs/core';
3067
+ import { ProdottoSchema } from '@/collections/prodotti';
3068
+
3069
+ export const ProdottiListSchema = BaseSectionData.extend({
3070
+ eyebrow: z.string().optional().describe('ui:text'),
3071
+ title: z.string().describe('ui:text'),
3072
+ description: z.string().optional().describe('ui:textarea'),
3073
+ items: z.record(z.string(), ProdottoSchema).describe('ui:collection-ref:catalogo'),
3074
+ });
3075
+
3076
+ export const ProdottiListSettingsSchema = z.object({});
3077
+
3078
+ END_OF_FILE_CONTENT
3079
+ echo "Creating src/components/prodotti-list/types.ts..."
3080
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotti-list/types.ts"
3081
+ import type { z } from 'zod';
3082
+ import type { ProdottiListSchema, ProdottiListSettingsSchema } from './schema';
3083
+
3084
+ export type ProdottiListData = z.infer<typeof ProdottiListSchema>;
3085
+ export type ProdottiListSettings = z.infer<typeof ProdottiListSettingsSchema>;
3086
+
3087
+ END_OF_FILE_CONTENT
3088
+ mkdir -p "src/components/prodotto-detail"
3089
+ echo "Creating src/components/prodotto-detail/View.tsx..."
3090
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/View.tsx"
3091
+ import type { ProdottoDetailData, ProdottoDetailSettings } from './types';
3092
+
3093
+ type ProdottoDetailViewProps = {
3094
+ data: ProdottoDetailData;
3095
+ settings?: ProdottoDetailSettings;
3096
+ };
3097
+
3098
+ function formatPrice(price: number, currency?: string): string {
3099
+ return `${(price / 100).toFixed(2)} ${currency ?? 'EUR'}`;
3100
+ }
3101
+
3102
+ /** RSC-safe product detail — resolved from `collection:current` (projected source). */
3103
+ export function ProdottoDetailView({ data }: ProdottoDetailViewProps) {
3104
+ const prodotto = data.item;
3105
+
3106
+ return (
3107
+ <main className="min-h-screen bg-background px-6 py-16 text-foreground">
3108
+ <article
3109
+ data-jp-field="item"
3110
+ data-jp-item-id={prodotto.id}
3111
+ data-jp-item-field="item"
3112
+ className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-card p-8 shadow-sm"
3113
+ >
3114
+ <a href="/prodotti" className="text-sm font-medium text-muted-foreground hover:text-foreground">
3115
+ {data.backLabel}
3116
+ </a>
3117
+ <h1 className="mt-10 text-3xl font-semibold">{prodotto.title}</h1>
3118
+ <p className="mt-3 text-lg">{formatPrice(prodotto.price, prodotto.currency)}</p>
3119
+ {prodotto.description ? (
3120
+ <p className="mt-6 text-muted-foreground">{prodotto.description}</p>
3121
+ ) : null}
3122
+ </article>
3123
+ </main>
3124
+ );
3125
+ }
3126
+
3127
+ END_OF_FILE_CONTENT
3128
+ echo "Creating src/components/prodotto-detail/index.ts..."
3129
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/index.ts"
3130
+ export { ProdottoDetailView } from './View';
3131
+ export { ProdottoDetailSchema, ProdottoDetailSettingsSchema } from './schema';
3132
+ export type { ProdottoDetailData, ProdottoDetailSettings } from './types';
3133
+
3134
+ END_OF_FILE_CONTENT
3135
+ echo "Creating src/components/prodotto-detail/schema.ts..."
3136
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/schema.ts"
3137
+ import { z } from 'zod';
3138
+ import { BaseSectionData } from '@olonjs/core';
3139
+ import { ProdottoSchema } from '@/collections/prodotti';
3140
+
3141
+ export const ProdottoDetailSchema = BaseSectionData.extend({
3142
+ item: ProdottoSchema.describe('ui:collection-ref'),
3143
+ backLabel: z.string().default('Torna al catalogo').describe('ui:text'),
3144
+ });
3145
+
3146
+ export const ProdottoDetailSettingsSchema = z.object({});
3147
+
3148
+ END_OF_FILE_CONTENT
3149
+ echo "Creating src/components/prodotto-detail/types.ts..."
3150
+ cat << 'END_OF_FILE_CONTENT' > "src/components/prodotto-detail/types.ts"
3151
+ import type { z } from 'zod';
3152
+ import type { ProdottoDetailSchema, ProdottoDetailSettingsSchema } from './schema';
3153
+
3154
+ export type ProdottoDetailData = z.infer<typeof ProdottoDetailSchema>;
3155
+ export type ProdottoDetailSettings = z.infer<typeof ProdottoDetailSettingsSchema>;
3156
+
2664
3157
  END_OF_FILE_CONTENT
2665
3158
  mkdir -p "src/components/webmcp"
2666
3159
  echo "Creating src/components/webmcp/WebMcpVisitorRuntime.tsx..."
@@ -2668,16 +3161,38 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/webmcp/WebMcpVisitorRuntime.tsx"
2668
3161
  'use client';
2669
3162
 
2670
3163
  import { useEffect } from 'react';
3164
+ import { registerWebMcpTool } from '@olonjs/core';
2671
3165
  import { bootstrapVisitorWebMcpRuntime } from '@/lib/webmcp/bootstrapVisitorWebMcpRuntime';
3166
+ // Side effect: registers the submit-form executor (spec 03). Client-only import.
3167
+ import '@/lib/webmcp/submitFormTool';
3168
+ import { visitorToolDefs } from '@/lib/webmcp/visitorToolDefs';
2672
3169
 
2673
3170
  /**
2674
- * Client island: WebMCP imperative API on public pages (readResource / listTools empty).
2675
- * Must not import @olonjs/studio.
3171
+ * Client island: WebMCP imperative API on public pages.
3172
+ * Installs the polyfill and registers the public tools enabled for the page
3173
+ * (spec 02-webmcp-tool-public-runtime). Must not import @olonjs/studio.
3174
+ *
3175
+ * `toolNames` are computed server-side from the sections present on the page
3176
+ * (functions cannot cross the RSC boundary — only names travel).
2676
3177
  */
2677
- export function WebMcpVisitorRuntime() {
3178
+ export function WebMcpVisitorRuntime({ toolNames = [] }: { toolNames?: string[] }) {
2678
3179
  useEffect(() => {
2679
3180
  bootstrapVisitorWebMcpRuntime();
2680
- }, []);
3181
+ const unregister = toolNames.map((name) => {
3182
+ const def = visitorToolDefs[name];
3183
+ if (!def) {
3184
+ console.warn(`[WebMCP] visitor tool "${name}" has no executor definition.`);
3185
+ return () => undefined;
3186
+ }
3187
+ return registerWebMcpTool({
3188
+ ...def,
3189
+ inputSchema: def.inputSchema ?? { type: 'object', additionalProperties: false, properties: {} },
3190
+ });
3191
+ });
3192
+ return () => {
3193
+ unregister.forEach((stop) => stop());
3194
+ };
3195
+ }, [toolNames]);
2681
3196
 
2682
3197
  return null;
2683
3198
  }
@@ -3062,8 +3577,8 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/collections/libri/libri.json"
3062
3577
  "summary": "Fuga, destino e sogno si intrecciano in un romanzo sospeso tra reale e mitico."
3063
3578
  },
3064
3579
  "americanah": {
3065
- "id": "americanah",
3066
- "title": "Americanah",
3580
+ "id": "americana",
3581
+ "title": "Americana",
3067
3582
  "author": {
3068
3583
  "$ref": "../autori/autori.json#/chimamanda-ngozi-adichie"
3069
3584
  },
@@ -3134,6 +3649,10 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
3134
3649
  "label": "Get started",
3135
3650
  "href": "#Getstarted"
3136
3651
  },
3652
+ {
3653
+ "label": "Prodotti",
3654
+ "href": "/prodotti"
3655
+ },
3137
3656
  {
3138
3657
  "label": "GitHub",
3139
3658
  "href": "https://github.com/olonjs/core"
@@ -3388,31 +3907,30 @@ mkdir -p "src/data/pages"
3388
3907
  mkdir -p "src/data/pages/authors"
3389
3908
  echo "Creating src/data/pages/authors.json..."
3390
3909
  cat << 'END_OF_FILE_CONTENT' > "src/data/pages/authors.json"
3391
- {
3392
- "id": "authors-page",
3393
- "slug": "authors",
3394
- "meta": {
3395
- "title": "Authors",
3396
- "description": "Author directory powered by the autori collection."
3397
- },
3398
- "sections": [
3399
- {
3400
- "id": "authors-list-1",
3401
- "type": "authors-list",
3402
- "data": {
3403
- "anchorId": "authors",
3404
- "eyebrow": "Collection demo",
3405
- "title": "Authors",
3406
- "description": "A collection-backed directory of authors referenced by books.",
3407
- "items": {
3408
- "$ref": "../collections/autori/autori.json"
3409
- }
3410
- }
3411
- }
3412
- ],
3413
- "global-header": false
3414
- }
3415
-
3910
+ {
3911
+ "id": "authors-page",
3912
+ "slug": "authors",
3913
+ "meta": {
3914
+ "title": "Authors",
3915
+ "description": "Author directory powered by the autori collection."
3916
+ },
3917
+ "sections": [
3918
+ {
3919
+ "id": "authors-list-1",
3920
+ "type": "authors-list",
3921
+ "data": {
3922
+ "anchorId": "authors",
3923
+ "eyebrow": "Collection demo",
3924
+ "title": "Authors",
3925
+ "description": "A collection-backed directory of authors referenced by books.",
3926
+ "items": {
3927
+ "$ref": "../collections/autori/autori.json"
3928
+ }
3929
+ }
3930
+ }
3931
+ ],
3932
+ "global-header": false
3933
+ }
3416
3934
  END_OF_FILE_CONTENT
3417
3935
  mkdir -p "src/data/pages/authors/[authorId]"
3418
3936
  echo "Creating src/data/pages/authors/[authorId]/libri.json..."
@@ -3559,46 +4077,111 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/libri/[slug].json"
3559
4077
  "global-header": false
3560
4078
  }
3561
4079
 
4080
+ END_OF_FILE_CONTENT
4081
+ mkdir -p "src/data/pages/prodotti"
4082
+ echo "Creating src/data/pages/prodotti.json..."
4083
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/prodotti.json"
4084
+ {
4085
+ "id": "prodotti-page",
4086
+ "slug": "prodotti",
4087
+ "meta": {
4088
+ "title": "Prodotti",
4089
+ "description": "Catalogo proiettato da una sorgente esterna (demo @olonjs/sources)."
4090
+ },
4091
+ "sections": [
4092
+ {
4093
+ "id": "prodotti-list-1",
4094
+ "type": "prodotti-list",
4095
+ "data": {
4096
+ "anchorId": "catalogo-prodotti",
4097
+ "eyebrow": "Projected source demo",
4098
+ "title": "Prodotti",
4099
+ "description": "Catalogo letto da una sorgente esterna, validato con la sua Zod shape e proiettato nel contratto OlonJS.",
4100
+ "items": {
4101
+ "$ref": "../collections/catalogo/catalogo.json"
4102
+ }
4103
+ }
4104
+ }
4105
+ ],
4106
+ "global-header": false
4107
+ }
4108
+
4109
+ END_OF_FILE_CONTENT
4110
+ echo "Creating src/data/pages/prodotti/[slug].json..."
4111
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/prodotti/[slug].json"
4112
+ {
4113
+ "id": "prodotto-detail-page",
4114
+ "slug": "prodotti/[slug]",
4115
+ "meta": {
4116
+ "title": "Dettaglio prodotto",
4117
+ "description": "Pagina dinamica alimentata dalla sorgente esterna catalogo."
4118
+ },
4119
+ "sections": [
4120
+ {
4121
+ "id": "prodotto-detail-1",
4122
+ "type": "prodotto-detail",
4123
+ "data": {
4124
+ "anchorId": "dettaglio-prodotto",
4125
+ "item": {
4126
+ "$ref": "collection:current"
4127
+ },
4128
+ "backLabel": "← Torna al catalogo"
4129
+ }
4130
+ }
4131
+ ],
4132
+ "collection": {
4133
+ "source": "catalogo",
4134
+ "paramKey": "slug"
4135
+ },
4136
+ "global-header": false
4137
+ }
4138
+
3562
4139
  END_OF_FILE_CONTENT
3563
4140
  # SKIP: src/index.css is binary and cannot be embedded as text.
3564
4141
  mkdir -p "src/lib"
3565
4142
  echo "Creating src/lib/CollectionRegistry.ts..."
3566
4143
  cat << 'END_OF_FILE_CONTENT' > "src/lib/CollectionRegistry.ts"
3567
- import { AutoriCollectionSchema } from '@/collections/autori';
3568
- import { LibriCollectionSchema } from '@/collections/libri';
3569
-
3570
- export const CollectionRegistry = {
3571
- autori: AutoriCollectionSchema,
3572
- libri: LibriCollectionSchema,
3573
- } as const;
3574
-
3575
- export type CollectionType = keyof typeof CollectionRegistry;
4144
+ import { AutoriCollectionSchema } from '@/collections/autori';
4145
+ import { LibriCollectionSchema } from '@/collections/libri';
4146
+ import { ProdottiCollectionSchema } from '@/collections/prodotti';
4147
+
4148
+ export const CollectionRegistry = {
4149
+ autori: AutoriCollectionSchema,
4150
+ libri: LibriCollectionSchema,
4151
+ catalogo: ProdottiCollectionSchema,
4152
+ } as const;
4153
+
4154
+ export type CollectionType = keyof typeof CollectionRegistry;
3576
4155
 
3577
4156
  END_OF_FILE_CONTENT
3578
4157
  echo "Creating src/lib/ComponentRegistry.tsx..."
3579
4158
  cat << 'END_OF_FILE_CONTENT' > "src/lib/ComponentRegistry.tsx"
3580
- import type React from 'react';
3581
- import type { SectionType } from '@/types';
3582
- import type { SectionComponentPropsMap } from '@/types';
3583
- import { AuthorsListView } from '@/components/authors-list';
3584
- import { BookDetailView } from '@/components/book-detail';
3585
- import { BooksListView } from '@/components/books-list';
3586
- import { EmptyTenantView } from '@/components/empty-tenant';
3587
- import { FooterView } from '@/components/footer';
3588
- import { FormDemoView } from '@/components/form-demo';
3589
- import { HeaderView } from '@/components/header';
3590
-
3591
- export const ComponentRegistry: {
3592
- [K in SectionType]: React.FC<SectionComponentPropsMap[K]>;
3593
- } = {
3594
- 'authors-list': AuthorsListView as React.FC<SectionComponentPropsMap['authors-list']>,
3595
- 'book-detail': BookDetailView as React.FC<SectionComponentPropsMap['book-detail']>,
3596
- 'books-list': BooksListView as React.FC<SectionComponentPropsMap['books-list']>,
3597
- 'empty-tenant': EmptyTenantView as React.FC<SectionComponentPropsMap['empty-tenant']>,
3598
- footer: FooterView as React.FC<SectionComponentPropsMap['footer']>,
3599
- 'form-demo': FormDemoView as React.FC<SectionComponentPropsMap['form-demo']>,
3600
- header: HeaderView as React.FC<SectionComponentPropsMap['header']>,
3601
- };
4159
+ import type React from 'react';
4160
+ import type { SectionType } from '@/types';
4161
+ import type { SectionComponentPropsMap } from '@/types';
4162
+ import { AuthorsListView } from '@/components/authors-list';
4163
+ import { BookDetailView } from '@/components/book-detail';
4164
+ import { BooksListView } from '@/components/books-list';
4165
+ import { EmptyTenantView } from '@/components/empty-tenant';
4166
+ import { FooterView } from '@/components/footer';
4167
+ import { FormDemoView } from '@/components/form-demo';
4168
+ import { HeaderView } from '@/components/header';
4169
+ import { ProdottoDetailView } from '@/components/prodotto-detail';
4170
+ import { ProdottiListView } from '@/components/prodotti-list';
4171
+
4172
+ export const ComponentRegistry: {
4173
+ [K in SectionType]: React.FC<SectionComponentPropsMap[K]>;
4174
+ } = {
4175
+ 'authors-list': AuthorsListView as React.FC<SectionComponentPropsMap['authors-list']>,
4176
+ 'book-detail': BookDetailView as React.FC<SectionComponentPropsMap['book-detail']>,
4177
+ 'books-list': BooksListView as React.FC<SectionComponentPropsMap['books-list']>,
4178
+ 'empty-tenant': EmptyTenantView as React.FC<SectionComponentPropsMap['empty-tenant']>,
4179
+ footer: FooterView as React.FC<SectionComponentPropsMap['footer']>,
4180
+ 'form-demo': FormDemoView as React.FC<SectionComponentPropsMap['form-demo']>,
4181
+ header: HeaderView as React.FC<SectionComponentPropsMap['header']>,
4182
+ 'prodotti-list': ProdottiListView as React.FC<SectionComponentPropsMap['prodotti-list']>,
4183
+ 'prodotto-detail': ProdottoDetailView as React.FC<SectionComponentPropsMap['prodotto-detail']>,
4184
+ };
3602
4185
 
3603
4186
  END_OF_FILE_CONTENT
3604
4187
  echo "Creating src/lib/IconResolver.tsx..."
@@ -5152,13 +5735,57 @@ END_OF_FILE_CONTENT
5152
5735
  echo "Creating src/lib/loaders/loadPublicPageBundleForRequest.ts..."
5153
5736
  cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadPublicPageBundleForRequest.ts"
5154
5737
  import type { PublicPageContentBundle } from '@olonjs/next/server';
5738
+ import { resolvePageMatchFromRegistry } from '@olonjs/core';
5739
+ import { collectCollectionSources, resolveCollectionSources } from '@olonjs/sources';
5155
5740
  import {
5156
5741
  buildServerApiCandidates,
5157
5742
  type ServerCloudBootSource,
5158
5743
  } from '@/lib/env/serverCloudPolicy';
5744
+ import { collectionSources } from '@/sources';
5159
5745
  import { loadLivePublicPageBundle } from './loadLivePublicPageBundle';
5160
5746
  import { loadLocalPublicPageBundle } from './loadLocalPublicPageBundle';
5161
5747
 
5748
+ /**
5749
+ * Project external collection sources referenced by the requested slug's page
5750
+ * (ADR-0018). Without this, page JSON endpoints would expose unresolved $refs.
5751
+ */
5752
+ export async function withProjectedSources(
5753
+ bundle: PublicPageContentBundle,
5754
+ slug: string,
5755
+ appRoot?: string,
5756
+ ): Promise<PublicPageContentBundle> {
5757
+ const match = resolvePageMatchFromRegistry(
5758
+ bundle.pages as never,
5759
+ slug
5760
+ );
5761
+ if (!match) return bundle;
5762
+
5763
+ const needed = collectCollectionSources(match.page).filter(
5764
+ (name) => name in collectionSources
5765
+ );
5766
+ if (needed.length === 0) return bundle;
5767
+
5768
+ const resolved = await resolveCollectionSources({
5769
+ sources: collectionSources as never,
5770
+ needed,
5771
+ params: match.params,
5772
+ });
5773
+
5774
+ return {
5775
+ ...bundle,
5776
+ collections: {
5777
+ ...(bundle.collections ?? {}),
5778
+ ...(resolved.collections as NonNullable<PublicPageContentBundle['collections']>),
5779
+ },
5780
+ collectionSchemas: {
5781
+ ...(bundle.collectionSchemas ?? {}),
5782
+ ...(resolved.collectionSchemas as NonNullable<
5783
+ PublicPageContentBundle['collectionSchemas']
5784
+ >),
5785
+ },
5786
+ };
5787
+ }
5788
+
5162
5789
  export type LoadPublicPageBundleForRequestInput = {
5163
5790
  bootSource: ServerCloudBootSource;
5164
5791
  slug: string;
@@ -5190,18 +5817,76 @@ export async function loadPublicPageBundleForRequest(
5190
5817
  if (!apiUrl || !apiKey) {
5191
5818
  throw new Error('Live public page JSON requires cloud API URL and key');
5192
5819
  }
5193
- return loadLivePublicPageBundle({
5820
+ const liveBundle = await loadLivePublicPageBundle({
5194
5821
  slug: input.slug,
5195
5822
  apiBases: buildServerApiCandidates(apiUrl),
5196
5823
  apiKey,
5197
5824
  appRoot,
5198
5825
  fetchImpl: input.fetchImpl,
5199
5826
  });
5827
+ return withProjectedSources(liveBundle, input.slug, appRoot);
5200
5828
  }
5201
5829
 
5202
- return loadLocalPublicPageBundle(appRoot);
5830
+ const localBundle = await loadLocalPublicPageBundle(appRoot);
5831
+ return withProjectedSources(localBundle, input.slug, appRoot);
5203
5832
  }
5204
5833
 
5834
+ END_OF_FILE_CONTENT
5835
+ echo "Creating src/lib/loaders/loadVisitorPageBundle.ts..."
5836
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/loadVisitorPageBundle.ts"
5837
+ import type { JsonPagesConfig, PageConfig } from '@olonjs/core';
5838
+ import { collectCollectionSources, resolveCollectionSources } from '@olonjs/sources';
5839
+ import { CollectionRegistry } from '@/lib/CollectionRegistry';
5840
+ import { collectionSources } from '@/sources';
5841
+ import { getFileCollections } from './getFileCollections';
5842
+
5843
+ export interface VisitorPageBundle {
5844
+ collections: NonNullable<JsonPagesConfig['collections']>;
5845
+ collectionSchemas: JsonPagesConfig['collectionSchemas'];
5846
+ /** Provenance of the projected sources resolved for this route. */
5847
+ sourceProvenance: Record<string, { fetchedAt: string; readOnly: boolean }>;
5848
+ }
5849
+
5850
+ /**
5851
+ * Load the per-route content bundle: file-backed collections plus the external
5852
+ * sources this page needs (projected, validated, read-only).
5853
+ *
5854
+ * Sources are resolved ONLY when referenced by the page (ADR-0018 / spec
5855
+ * external-collection-sources). Source names not registered as external
5856
+ * sources are file-backed and skipped here.
5857
+ */
5858
+ export async function loadVisitorPageBundle(input: {
5859
+ page: PageConfig;
5860
+ params: Record<string, string>;
5861
+ appRoot?: string;
5862
+ }): Promise<VisitorPageBundle> {
5863
+ const fileCollections = getFileCollections(input.appRoot) as NonNullable<
5864
+ JsonPagesConfig['collections']
5865
+ >;
5866
+
5867
+ const needed = collectCollectionSources(input.page).filter(
5868
+ (name) => name in collectionSources
5869
+ );
5870
+
5871
+ const resolved = await resolveCollectionSources({
5872
+ sources: collectionSources as never,
5873
+ needed,
5874
+ params: input.params,
5875
+ });
5876
+
5877
+ return {
5878
+ collections: {
5879
+ ...fileCollections,
5880
+ ...(resolved.collections as NonNullable<JsonPagesConfig['collections']>),
5881
+ },
5882
+ collectionSchemas: {
5883
+ ...(CollectionRegistry as JsonPagesConfig['collectionSchemas']),
5884
+ ...(resolved.collectionSchemas as JsonPagesConfig['collectionSchemas']),
5885
+ },
5886
+ sourceProvenance: resolved.provenance,
5887
+ };
5888
+ }
5889
+
5205
5890
  END_OF_FILE_CONTENT
5206
5891
  echo "Creating src/lib/loaders/publishedContent.test.ts..."
5207
5892
  cat << 'END_OF_FILE_CONTENT' > "src/lib/loaders/publishedContent.test.ts"
@@ -5347,45 +6032,49 @@ export function resolveVisitorShell(page: PageConfig, site: SiteConfig): Visitor
5347
6032
  END_OF_FILE_CONTENT
5348
6033
  echo "Creating src/lib/schemas.ts..."
5349
6034
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
5350
- import { AuthorsListSchema } from '@/components/authors-list';
5351
- import { BookDetailSchema } from '@/components/book-detail';
5352
- import { BooksListSchema } from '@/components/books-list';
5353
- import { EmptyTenantSchema } from '@/components/empty-tenant';
5354
- import { FooterSchema } from '@/components/footer';
5355
- import { FormDemoSchema, FormDemoSubmissionSchema } from '@/components/form-demo';
5356
- import { HeaderSchema } from '@/components/header';
5357
-
5358
- export const SECTION_SCHEMAS = {
5359
- 'authors-list': AuthorsListSchema,
5360
- 'book-detail': BookDetailSchema,
5361
- 'books-list': BooksListSchema,
5362
- 'empty-tenant': EmptyTenantSchema,
5363
- footer: FooterSchema,
5364
- 'form-demo': FormDemoSchema,
5365
- header: HeaderSchema,
5366
- } as const;
5367
-
5368
- /**
5369
- * Registry of per-section-type submission schemas. Keys MUST match a key of
5370
- * SECTION_SCHEMAS. A section type appearing here is declaring itself as
5371
- * MCP-submittable: the Zod schema describes the payload accepted by the form.
5372
- *
5373
- * See ADR-0002 (docs/decisions/ADR-0002-form-submission-schemas.md).
5374
- */
5375
- export const SECTION_SUBMISSION_SCHEMAS = {
5376
- 'form-demo': FormDemoSubmissionSchema,
5377
- } as const;
5378
-
5379
- export type SectionType = keyof typeof SECTION_SCHEMAS;
5380
-
5381
- export {
5382
- BaseSectionData,
5383
- BaseArrayItem,
5384
- BaseCollectionItem,
5385
- BaseSectionSettingsSchema,
5386
- CtaSchema,
5387
- ImageSelectionSchema,
5388
- } from '@olonjs/core';
6035
+ import { AuthorsListSchema } from '@/components/authors-list';
6036
+ import { BookDetailSchema } from '@/components/book-detail';
6037
+ import { BooksListSchema } from '@/components/books-list';
6038
+ import { EmptyTenantSchema } from '@/components/empty-tenant';
6039
+ import { FooterSchema } from '@/components/footer';
6040
+ import { FormDemoSchema, FormDemoSubmissionSchema } from '@/components/form-demo';
6041
+ import { HeaderSchema } from '@/components/header';
6042
+ import { ProdottoDetailSchema } from '@/components/prodotto-detail';
6043
+ import { ProdottiListSchema } from '@/components/prodotti-list';
6044
+
6045
+ export const SECTION_SCHEMAS = {
6046
+ 'authors-list': AuthorsListSchema,
6047
+ 'book-detail': BookDetailSchema,
6048
+ 'books-list': BooksListSchema,
6049
+ 'empty-tenant': EmptyTenantSchema,
6050
+ footer: FooterSchema,
6051
+ 'form-demo': FormDemoSchema,
6052
+ header: HeaderSchema,
6053
+ 'prodotti-list': ProdottiListSchema,
6054
+ 'prodotto-detail': ProdottoDetailSchema,
6055
+ } as const;
6056
+
6057
+ /**
6058
+ * Registry of per-section-type submission schemas. Keys MUST match a key of
6059
+ * SECTION_SCHEMAS. A section type appearing here is declaring itself as
6060
+ * MCP-submittable: the Zod schema describes the payload accepted by the form.
6061
+ *
6062
+ * See ADR-0002 (docs/decisions/ADR-0002-form-submission-schemas.md).
6063
+ */
6064
+ export const SECTION_SUBMISSION_SCHEMAS = {
6065
+ 'form-demo': FormDemoSubmissionSchema,
6066
+ } as const;
6067
+
6068
+ export type SectionType = keyof typeof SECTION_SCHEMAS;
6069
+
6070
+ export {
6071
+ BaseSectionData,
6072
+ BaseArrayItem,
6073
+ BaseCollectionItem,
6074
+ BaseSectionSettingsSchema,
6075
+ CtaSchema,
6076
+ ImageSelectionSchema,
6077
+ } from '@olonjs/core';
5389
6078
 
5390
6079
  END_OF_FILE_CONTENT
5391
6080
  echo "Creating src/lib/utils.ts..."
@@ -5497,6 +6186,66 @@ export function buildEnabledWebMcpConfig(namespace?: string): {
5497
6186
  };
5498
6187
  }
5499
6188
 
6189
+ END_OF_FILE_CONTENT
6190
+ echo "Creating src/lib/webmcp/resolvePublicToolNames.test.ts..."
6191
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/resolvePublicToolNames.test.ts"
6192
+ import { describe, expect, it } from 'vitest';
6193
+ import { resolvePublicToolNames } from './resolvePublicToolNames';
6194
+
6195
+ const sectionPublicTools = {
6196
+ 'form-demo': ['submit-form'],
6197
+ 'shop-catalog': ['search-product', 'add-to-cart'],
6198
+ };
6199
+
6200
+ describe('resolvePublicToolNames', () => {
6201
+ it('returns the public tools of the sections present on the page, deduplicated, in page order', () => {
6202
+ const sections = [
6203
+ { type: 'form-demo' },
6204
+ { type: 'hero' },
6205
+ { type: 'form-demo' },
6206
+ { type: 'shop-catalog' },
6207
+ ];
6208
+ expect(resolvePublicToolNames(sections, sectionPublicTools)).toEqual([
6209
+ 'submit-form',
6210
+ 'search-product',
6211
+ 'add-to-cart',
6212
+ ]);
6213
+ });
6214
+
6215
+ it('returns an empty list when no section declares tools', () => {
6216
+ expect(resolvePublicToolNames([{ type: 'hero' }], sectionPublicTools)).toEqual([]);
6217
+ });
6218
+
6219
+ it('returns an empty list for an empty page', () => {
6220
+ expect(resolvePublicToolNames([], sectionPublicTools)).toEqual([]);
6221
+ });
6222
+
6223
+ it('ignores tool names not present in the section map', () => {
6224
+ expect(resolvePublicToolNames([{ type: 'footer' }], sectionPublicTools)).toEqual([]);
6225
+ });
6226
+ });
6227
+
6228
+ END_OF_FILE_CONTENT
6229
+ echo "Creating src/lib/webmcp/resolvePublicToolNames.ts..."
6230
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/resolvePublicToolNames.ts"
6231
+ /**
6232
+ * Pure helper (spec 02-webmcp-tool-public-runtime): computes which public
6233
+ * tool names are enabled for a page, from the section types present.
6234
+ * Deduplicated, in page order.
6235
+ */
6236
+ export function resolvePublicToolNames(
6237
+ sections: ReadonlyArray<{ type: string }>,
6238
+ sectionPublicTools: Record<string, readonly string[]>,
6239
+ ): string[] {
6240
+ const out: string[] = [];
6241
+ for (const section of sections) {
6242
+ for (const name of sectionPublicTools[section.type] ?? []) {
6243
+ if (!out.includes(name)) out.push(name);
6244
+ }
6245
+ }
6246
+ return out;
6247
+ }
6248
+
5500
6249
  END_OF_FILE_CONTENT
5501
6250
  mkdir -p "src/lib/webmcp/runtime"
5502
6251
  echo "Creating src/lib/webmcp/runtime/agenticSurface.test.ts..."
@@ -5654,6 +6403,36 @@ describe('robots + sitemap', () => {
5654
6403
  expect(xml).toContain('<lastmod>2026-09-10T10:00:00Z</lastmod>');
5655
6404
  });
5656
6405
  });
6406
+
6407
+ describe('declared public tools in runtime contracts', () => {
6408
+ const surfaceInput = {
6409
+ bundle,
6410
+ schemas,
6411
+ declaredTools: [
6412
+ {
6413
+ name: 'submit-form',
6414
+ description: 'Submit the contact form',
6415
+ surface: 'public',
6416
+ inputSchema: { type: 'object', properties: {} },
6417
+ },
6418
+ ] as const,
6419
+ };
6420
+
6421
+ it('emits declared tools with their surface in the page contract', () => {
6422
+ const contract = buildRuntimePageContract({ ...surfaceInput, slug: 'home' });
6423
+ const submit = contract!.tools.find((t) => t.name === 'submit-form');
6424
+ expect(submit).toBeDefined();
6425
+ expect((submit as { surface?: string }).surface).toBe('public');
6426
+ expect(contract!.tools.map((t) => t.name)).toEqual(['update-section', 'save', 'submit-form']);
6427
+ });
6428
+
6429
+ it('emits declared tools in the page manifest tools list with surface', () => {
6430
+ const manifest = buildRuntimePageManifest({ ...surfaceInput, slug: 'home' });
6431
+ const submit = manifest!.tools.find((t) => t.name === 'submit-form');
6432
+ expect(submit).toBeDefined();
6433
+ expect((submit as { surface?: string }).surface).toBe('public');
6434
+ });
6435
+ });
5657
6436
 
5658
6437
  END_OF_FILE_CONTENT
5659
6438
  echo "Creating src/lib/webmcp/runtime/agenticSurface.ts..."
@@ -5672,6 +6451,7 @@ import {
5672
6451
  webmcp,
5673
6452
  type JsonPagesConfig,
5674
6453
  type PageConfig,
6454
+ type WebMcpToolDef,
5675
6455
  } from '@olonjs/core';
5676
6456
  import type { PublicPageContentBundle } from '@olonjs/next/server';
5677
6457
 
@@ -5688,6 +6468,8 @@ export type RuntimeSurfaceInput = {
5688
6468
  bundle: PublicPageContentBundle;
5689
6469
  schemas: JsonPagesConfig['schemas'];
5690
6470
  submissionSchemas?: JsonPagesConfig['submissionSchemas'];
6471
+ /** Tenant-declared tools (public surface), appended to the contract's built-ins. */
6472
+ declaredTools?: readonly WebMcpToolDef[];
5691
6473
  };
5692
6474
 
5693
6475
  function joinSegments(segments: readonly string[]): string {
@@ -5776,6 +6558,7 @@ export function buildRuntimeSiteManifest(input: RuntimeSurfaceInput) {
5776
6558
  siteConfig: input.bundle.siteConfig,
5777
6559
  // Bundle carries SchemaLike; the manifest builder expects the Zod registry (same objects).
5778
6560
  collectionSchemas: input.bundle.collectionSchemas as never,
6561
+ declaredTools: input.declaredTools,
5779
6562
  });
5780
6563
  }
5781
6564
 
@@ -5788,6 +6571,7 @@ export function buildRuntimePageManifest(input: RuntimeSurfaceInput & { slug: st
5788
6571
  schemas: input.schemas,
5789
6572
  submissionSchemas: input.submissionSchemas,
5790
6573
  siteConfig: input.bundle.siteConfig,
6574
+ declaredTools: input.declaredTools,
5791
6575
  });
5792
6576
  }
5793
6577
 
@@ -5800,6 +6584,7 @@ export function buildRuntimePageContract(input: RuntimeSurfaceInput & { slug: st
5800
6584
  schemas: input.schemas,
5801
6585
  submissionSchemas: input.submissionSchemas,
5802
6586
  siteConfig: input.bundle.siteConfig,
6587
+ declaredTools: input.declaredTools,
5803
6588
  });
5804
6589
  }
5805
6590
 
@@ -5818,6 +6603,7 @@ export function buildRuntimeLlmsTxt(input: RuntimeSurfaceInput): string {
5818
6603
  schemas: input.schemas,
5819
6604
  submissionSchemas: input.submissionSchemas,
5820
6605
  siteConfig: input.bundle.siteConfig,
6606
+ declaredTools: input.declaredTools,
5821
6607
  });
5822
6608
  }
5823
6609
 
@@ -5927,6 +6713,7 @@ import { readServerCloudPolicy } from '@/lib/env/serverCloudPolicy';
5927
6713
  import { loadLocalPublicPageBundle } from '@/lib/loaders/loadLocalPublicPageBundle';
5928
6714
  import { loadPublicPageBundleForRequest } from '@/lib/loaders/loadPublicPageBundleForRequest';
5929
6715
  import { SECTION_SCHEMAS, SECTION_SUBMISSION_SCHEMAS } from '@/lib/schemas';
6716
+ import { declaredPublicTools } from '@/lib/webmcp/toolRegistry';
5930
6717
  import type { RuntimeSurfaceInput } from './agenticSurface';
5931
6718
 
5932
6719
  const schemas = SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'];
@@ -5949,7 +6736,7 @@ export async function loadRuntimeSurfaceForSlug(input: {
5949
6736
  apiUrl: policy.apiUrl,
5950
6737
  apiKey: policy.apiKey,
5951
6738
  });
5952
- return { bundle, schemas, submissionSchemas };
6739
+ return { bundle, schemas, submissionSchemas, declaredTools: declaredPublicTools };
5953
6740
  }
5954
6741
 
5955
6742
  /**
@@ -5958,7 +6745,7 @@ export async function loadRuntimeSurfaceForSlug(input: {
5958
6745
  */
5959
6746
  export function loadRuntimeSurfaceForSite(): RuntimeSurfaceInput {
5960
6747
  const bundle = loadLocalPublicPageBundle(path.resolve(process.cwd()));
5961
- return { bundle, schemas, submissionSchemas };
6748
+ return { bundle, schemas, submissionSchemas, declaredTools: declaredPublicTools };
5962
6749
  }
5963
6750
 
5964
6751
  /** Public base URL for absolute links (robots/sitemap). */
@@ -6018,6 +6805,236 @@ describe('next.config rewrites — runtime agentic surface', () => {
6018
6805
  });
6019
6806
  });
6020
6807
 
6808
+ END_OF_FILE_CONTENT
6809
+ echo "Creating src/lib/webmcp/submitFormTool.test.ts..."
6810
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/submitFormTool.test.ts"
6811
+ // @vitest-environment jsdom
6812
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6813
+
6814
+ const CLOUD_URL = 'https://cloud.example.com';
6815
+
6816
+ async function loadModules() {
6817
+ vi.resetModules();
6818
+ vi.stubEnv('NEXT_PUBLIC_OLONJS_CLOUD_URL', CLOUD_URL);
6819
+ vi.stubEnv('NEXT_PUBLIC_OLONJS_API_KEY', 'k');
6820
+ const [{ sectionPublicTools, declaredPublicTools }, { visitorToolDefs }] = await Promise.all([
6821
+ import('@/lib/webmcp/toolRegistry'),
6822
+ import('@/lib/webmcp/visitorToolDefs'),
6823
+ ]);
6824
+ // Same side-effect import the visitor island performs: registers the executor.
6825
+ await import('@/lib/webmcp/submitFormTool');
6826
+ return { sectionPublicTools, declaredPublicTools, visitorToolDefs };
6827
+ }
6828
+
6829
+ beforeEach(() => {
6830
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })));
6831
+ });
6832
+
6833
+ afterEach(() => {
6834
+ vi.unstubAllEnvs();
6835
+ vi.unstubAllGlobals();
6836
+ document.body.innerHTML = '';
6837
+ });
6838
+
6839
+ describe('submit-form tool wiring', () => {
6840
+ it('maps form-demo to submit-form and declares the public tool def', async () => {
6841
+ const { sectionPublicTools, declaredPublicTools } = await loadModules();
6842
+ expect(sectionPublicTools['form-demo']).toEqual(['submit-form']);
6843
+
6844
+ const def = declaredPublicTools.find((t) => t.name === 'submit-form');
6845
+ expect(def).toBeDefined();
6846
+ expect(def!.surface).toBe('public');
6847
+ expect(def!.inputSchema).toMatchObject({
6848
+ type: 'object',
6849
+ properties: { name: { type: 'string' }, email: { type: 'string' }, message: { type: 'string' } },
6850
+ });
6851
+ });
6852
+
6853
+ it('registers the executor and submits a valid payload to {cloudUrl}/form/send', async () => {
6854
+ const { visitorToolDefs } = await loadModules();
6855
+ document.body.innerHTML =
6856
+ '<form id="form-demo" data-olon-recipient="lead@example.com"></form>';
6857
+
6858
+ const tool = visitorToolDefs['submit-form'];
6859
+ expect(tool).toBeDefined();
6860
+
6861
+ await expect(
6862
+ tool.execute({ name: 'Ada', email: 'ada@example.com', message: 'Ciao' }),
6863
+ ).resolves.toEqual({ ok: true });
6864
+
6865
+ const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit];
6866
+ expect(url).toBe(`${CLOUD_URL}/form/send`);
6867
+ const payload = JSON.parse(init.body as string) as Record<string, unknown>;
6868
+ expect(payload).toMatchObject({
6869
+ recipientEmail: 'lead@example.com',
6870
+ section: 'form-demo',
6871
+ source: 'olon-webmcp',
6872
+ });
6873
+ });
6874
+
6875
+ it('rejects an invalid payload with a Zod validation error before any fetch', async () => {
6876
+ const { visitorToolDefs } = await loadModules();
6877
+ const tool = visitorToolDefs['submit-form'];
6878
+
6879
+ await expect(tool.execute({ name: '', email: 'not-an-email', message: '' })).rejects.toThrow();
6880
+ expect(vi.mocked(fetch)).not.toHaveBeenCalled();
6881
+ });
6882
+ });
6883
+
6884
+ END_OF_FILE_CONTENT
6885
+ echo "Creating src/lib/webmcp/submitFormTool.ts..."
6886
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/submitFormTool.ts"
6887
+ import { FormDemoSubmissionSchema } from '@/components/form-demo/schema';
6888
+ import { submitFormLead, type SubmitFormArgs } from '@/components/form-demo/submitFormLead';
6889
+ import { webmcpTools } from '@/components/form-demo/webmcpTools';
6890
+ import { visitorToolDefs } from './visitorToolDefs';
6891
+
6892
+ const submitFormDef = webmcpTools.find((tool) => tool.name === 'submit-form');
6893
+ if (!submitFormDef) {
6894
+ throw new Error('[WebMCP] form-demo capsule must declare the submit-form tool.');
6895
+ }
6896
+
6897
+ /**
6898
+ * Client-side executor registration for submit-form (spec 03).
6899
+ *
6900
+ * Imported as a side effect ONLY by the visitor island — never by server code:
6901
+ * the executor chain (`submitFormLead` → `tenantEnv` → `@olonjs/react`) is
6902
+ * client-bound. Validation uses the section's submission schema (ADR-0002),
6903
+ * the same shape agents see in `sectionSubmissionSchemas` of the page contract.
6904
+ */
6905
+ visitorToolDefs['submit-form'] = {
6906
+ ...submitFormDef,
6907
+ execute: async (rawArgs: unknown) => {
6908
+ const parsed = FormDemoSubmissionSchema.parse(rawArgs);
6909
+ const form = document.querySelector<HTMLFormElement>('form[data-olon-recipient]');
6910
+ const args = rawArgs as Record<string, unknown>;
6911
+ const sectionId =
6912
+ typeof args.sectionId === 'string' && args.sectionId.trim()
6913
+ ? args.sectionId.trim()
6914
+ : form?.id || 'form-demo';
6915
+ return submitFormLead(parsed as SubmitFormArgs, {
6916
+ recipientEmail: form?.dataset.olonRecipient ?? '',
6917
+ sectionId,
6918
+ pageSlug: window.location.pathname,
6919
+ path: 'form/send',
6920
+ });
6921
+ },
6922
+ };
6923
+
6924
+ END_OF_FILE_CONTENT
6925
+ echo "Creating src/lib/webmcp/toolRegistry.ts..."
6926
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/toolRegistry.ts"
6927
+ import type { WebMcpToolDef } from '@olonjs/core';
6928
+ import { webmcpTools as formDemoTools } from '@/components/form-demo';
6929
+
6930
+ /**
6931
+ * Tenant tool registry (spec 01-webmcp-tool-contract).
6932
+ *
6933
+ * Section capsules declare tools by exporting `webmcpTools` from their
6934
+ * `index.ts`. Aggregate them here:
6935
+ * - admin-surface defs + executors flow into the Studio island
6936
+ * (`AdminStudioClient` → `JsonPagesConfig.webmcp`);
6937
+ * - public-surface tool names flow into the visitor page via
6938
+ * `sectionPublicTools` (module 02).
6939
+ */
6940
+
6941
+ /** Admin-surface tool defs registered in the Studio island. */
6942
+ export const declaredAdminTools: readonly WebMcpToolDef[] = [];
6943
+
6944
+ /** Runtime executors for admin tools, keyed by tool name. */
6945
+ export const adminToolExecutors: Readonly<
6946
+ Record<string, (args: unknown) => Promise<unknown> | unknown>
6947
+ > = {};
6948
+
6949
+ /** Public tool names exposed per section type (consumed by module 02). */
6950
+ export const sectionPublicTools: Record<string, readonly string[]> = {
6951
+ 'form-demo': formDemoTools.map((tool) => tool.name),
6952
+ };
6953
+
6954
+ /** Public-surface tool defs emitted in the runtime page contracts (module 02). */
6955
+ export const declaredPublicTools: readonly WebMcpToolDef[] = [...formDemoTools];
6956
+
6957
+ END_OF_FILE_CONTENT
6958
+ echo "Creating src/lib/webmcp/visitorIsland.test.ts..."
6959
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/visitorIsland.test.ts"
6960
+ // @vitest-environment jsdom
6961
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6962
+ import { act, createElement } from 'react';
6963
+ import { createRoot, type Root } from 'react-dom/client';
6964
+ import { WebMcpVisitorRuntime } from '@/components/webmcp/WebMcpVisitorRuntime';
6965
+ import { visitorToolDefs } from '@/lib/webmcp/visitorToolDefs';
6966
+
6967
+ function protocol() {
6968
+ return (document as unknown as {
6969
+ modelContextProtocol: {
6970
+ listTools: () => Array<{ name: string }>;
6971
+ executeTool: (name: string, inputArgsJson: string) => Promise<string>;
6972
+ };
6973
+ }).modelContextProtocol;
6974
+ }
6975
+
6976
+ let root: Root | null = null;
6977
+ let container: HTMLDivElement | null = null;
6978
+
6979
+ afterEach(() => {
6980
+ delete visitorToolDefs['test-tool'];
6981
+ if (root) {
6982
+ act(() => root!.unmount());
6983
+ root = null;
6984
+ }
6985
+ container?.remove();
6986
+ container = null;
6987
+ const doc = document as unknown as { modelContext?: unknown; modelContextProtocol?: unknown };
6988
+ delete doc.modelContext;
6989
+ delete doc.modelContextProtocol;
6990
+ const win = window as unknown as { __olonWebMcpTools__?: unknown; __olonWebMcpControllers__?: unknown };
6991
+ delete win.__olonWebMcpTools__;
6992
+ delete win.__olonWebMcpControllers__;
6993
+ vi.restoreAllMocks();
6994
+ });
6995
+
6996
+ function mount(toolNames: string[]) {
6997
+ container = document.createElement('div');
6998
+ document.body.appendChild(container);
6999
+ root = createRoot(container);
7000
+ act(() => {
7001
+ root!.render(createElement(WebMcpVisitorRuntime, { toolNames }));
7002
+ });
7003
+ }
7004
+
7005
+ describe('WebMcpVisitorRuntime — public tool registration', () => {
7006
+ it('registers the declared tools for the page and executes them', async () => {
7007
+ visitorToolDefs['test-tool'] = {
7008
+ name: 'test-tool',
7009
+ description: 'Test tool',
7010
+ surface: 'public',
7011
+ inputSchema: { type: 'object', additionalProperties: false, properties: {} },
7012
+ execute: async (args: unknown) => ({ ok: true, args }),
7013
+ };
7014
+
7015
+ mount(['test-tool', 'unknown-tool']);
7016
+
7017
+ await vi.waitFor(() => {
7018
+ expect(protocol().listTools().map((t) => t.name)).toContain('test-tool');
7019
+ });
7020
+
7021
+ const result = await protocol().executeTool('test-tool', '{"q":1}');
7022
+ expect(JSON.parse(result)).toEqual({ ok: true, args: { q: 1 } });
7023
+
7024
+ act(() => root!.unmount());
7025
+ root = null;
7026
+
7027
+ await vi.waitFor(() => {
7028
+ expect(protocol().listTools().map((t) => t.name)).not.toContain('test-tool');
7029
+ });
7030
+ });
7031
+
7032
+ it('does not register anything when the page has no tool names', () => {
7033
+ mount([]);
7034
+ expect(protocol().listTools()).toEqual([]);
7035
+ });
7036
+ });
7037
+
6021
7038
  END_OF_FILE_CONTENT
6022
7039
  echo "Creating src/lib/webmcp/visitorPageWebMcpWiring.test.ts..."
6023
7040
  cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/visitorPageWebMcpWiring.test.ts"
@@ -6037,55 +7054,216 @@ describe('visitor page WebMCP wiring', () => {
6037
7054
  });
6038
7055
  });
6039
7056
 
7057
+ END_OF_FILE_CONTENT
7058
+ echo "Creating src/lib/webmcp/visitorToolDefs.ts..."
7059
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/visitorToolDefs.ts"
7060
+ import type { WebMcpToolDef } from '@olonjs/core';
7061
+
7062
+ export interface VisitorToolRegistration extends WebMcpToolDef {
7063
+ /** Client-side executor — the dev's domain logic (fetch, route handler, external service). */
7064
+ execute: (args: unknown) => Promise<unknown> | unknown;
7065
+ }
7066
+
7067
+ /**
7068
+ * Client-side registry of public tools: name → def with executor
7069
+ * (spec 02-webmcp-tool-public-runtime).
7070
+ *
7071
+ * The RSC page passes only the enabled tool NAMES (functions cannot cross the
7072
+ * RSC boundary); this registry resolves them to their executors at
7073
+ * registration time in the visitor island.
7074
+ *
7075
+ * Section capsules register here — module 03 adds 'submit-form'.
7076
+ */
7077
+ export const visitorToolDefs: Record<string, VisitorToolRegistration> = {};
7078
+
7079
+ END_OF_FILE_CONTENT
7080
+ mkdir -p "src/sources"
7081
+ echo "Creating src/sources/catalogo.test.ts..."
7082
+ cat << 'END_OF_FILE_CONTENT' > "src/sources/catalogo.test.ts"
7083
+ import { describe, expect, it } from 'vitest';
7084
+ import { runSourceConformance } from '@olonjs/sources';
7085
+ import { catalogoSource, fetchCatalogoFromExternalService } from './catalogo';
7086
+
7087
+ describe('catalogo (demo external source)', () => {
7088
+ it('passes the provider conformance suite', async () => {
7089
+ await expect(runSourceConformance(catalogoSource)).resolves.toBeUndefined();
7090
+ });
7091
+
7092
+ it('projects a deterministic document keyed by product id', async () => {
7093
+ const document = await fetchCatalogoFromExternalService();
7094
+
7095
+ expect(Object.keys(document).sort()).toEqual([
7096
+ '1984',
7097
+ 'dune',
7098
+ 'il-nome-della-rosa',
7099
+ 'neuromancer',
7100
+ ]);
7101
+ for (const [key, item] of Object.entries(document)) {
7102
+ expect(item.id).toBe(key);
7103
+ }
7104
+ });
7105
+
7106
+ it('surfaces failures for fallback/breaker policy to consume', async () => {
7107
+ await expect(fetchCatalogoFromExternalService({ fail: true })).rejects.toThrow(
7108
+ /unavailable/
7109
+ );
7110
+ });
7111
+ });
7112
+
7113
+ END_OF_FILE_CONTENT
7114
+ echo "Creating src/sources/catalogo.ts..."
7115
+ cat << 'END_OF_FILE_CONTENT' > "src/sources/catalogo.ts"
7116
+ import { z } from 'zod';
7117
+ import { defineCollectionSource, withCache } from '@olonjs/sources';
7118
+ import { ProdottoSchema, type Prodotto } from '@/collections/prodotti';
7119
+
7120
+ /**
7121
+ * Demo provider: a DETERMINISTIC, local stand-in for an external catalog
7122
+ * service (no network — keeps dev, tests and CI hermetic). The real-world twin
7123
+ * would be `src/sources/shopify.ts` implementing the same contract.
7124
+ *
7125
+ * The `shape` is the single declaration: it validates the projected document
7126
+ * and feeds the agent contracts (`/schemas/collections/catalogo.schema.json`).
7127
+ */
7128
+
7129
+ const CATALOGO_FIXTURE: Record<string, Prodotto> = {
7130
+ dune: {
7131
+ id: 'dune',
7132
+ title: 'Dune — Edizione illustrata',
7133
+ price: 2490,
7134
+ currency: 'EUR',
7135
+ description: 'Arrakis, spezia e politica: il classico di Frank Herbert.',
7136
+ },
7137
+ '1984': {
7138
+ id: '1984',
7139
+ title: '1984',
7140
+ price: 1490,
7141
+ currency: 'EUR',
7142
+ description: 'Il Grande Fratello e il controllo del linguaggio.',
7143
+ },
7144
+ neuromancer: {
7145
+ id: 'neuromancer',
7146
+ title: 'Neuromancer',
7147
+ price: 1890,
7148
+ currency: 'EUR',
7149
+ description: 'Il romanzo che ha definito il cyberpunk.',
7150
+ },
7151
+ 'il-nome-della-rosa': {
7152
+ id: 'il-nome-della-rosa',
7153
+ title: 'Il nome della rosa',
7154
+ price: 2100,
7155
+ currency: 'EUR',
7156
+ description: 'Un giallo medievale tra biblioteche e potere.',
7157
+ },
7158
+ };
7159
+
7160
+ export interface ExternalCatalogServiceOptions {
7161
+ /** Simulated latency in ms (default 0 — deterministic tests). */
7162
+ latencyMs?: number;
7163
+ /** Force a failure, to exercise fallback/breaker policy. */
7164
+ fail?: boolean;
7165
+ signal?: AbortSignal;
7166
+ }
7167
+
7168
+ /** Stand-in for the external system of record (Shopify/PIM/…). */
7169
+ export async function fetchCatalogoFromExternalService(
7170
+ options: ExternalCatalogServiceOptions = {}
7171
+ ): Promise<Record<string, Prodotto>> {
7172
+ const { latencyMs = 0, fail = false, signal } = options;
7173
+
7174
+ if (latencyMs > 0) {
7175
+ await new Promise((resolve) => setTimeout(resolve, latencyMs));
7176
+ }
7177
+ if (signal?.aborted) throw new Error('The catalog request was aborted');
7178
+ if (fail) throw new Error('External catalog service unavailable');
7179
+
7180
+ return JSON.parse(JSON.stringify(CATALOGO_FIXTURE)) as Record<string, Prodotto>;
7181
+ }
7182
+
7183
+ export const catalogoSource = withCache(
7184
+ defineCollectionSource({
7185
+ shape: z.record(z.string(), ProdottoSchema),
7186
+ resolve: async ({ signal }) => fetchCatalogoFromExternalService({ signal }),
7187
+ readOnly: true,
7188
+ }),
7189
+ { ttlMs: 60_000, swrMs: 300_000 }
7190
+ );
7191
+
7192
+ END_OF_FILE_CONTENT
7193
+ echo "Creating src/sources/index.ts..."
7194
+ cat << 'END_OF_FILE_CONTENT' > "src/sources/index.ts"
7195
+ import { catalogoSource } from './catalogo';
7196
+
7197
+ /**
7198
+ * Tenant source registry. Adding a provider is a one-line change here — no
7199
+ * changes to @olonjs/core (ADR-0018). Sources not listed here are file-backed
7200
+ * collections loaded from src/data/collections.
7201
+ */
7202
+ export const collectionSources = {
7203
+ catalogo: catalogoSource,
7204
+ } as const;
7205
+
7206
+ export type CollectionSourceName = keyof typeof collectionSources;
7207
+
6040
7208
  END_OF_FILE_CONTENT
6041
7209
  echo "Creating src/types.ts..."
6042
7210
  cat << 'END_OF_FILE_CONTENT' > "src/types.ts"
6043
- import type { AuthorsListData, AuthorsListSettings } from '@/components/authors-list';
6044
- import type { BookDetailData, BookDetailSettings } from '@/components/book-detail';
6045
- import type { BooksListData, BooksListSettings } from '@/components/books-list';
6046
- import type { EmptyTenantData, EmptyTenantSettings } from '@/components/empty-tenant';
6047
- import type { FooterData, FooterSettings } from '@/components/footer';
6048
- import type { FormDemoData, FormDemoSettings } from '@/components/form-demo';
6049
- import type { HeaderData, HeaderSettings } from '@/components/header';
6050
- import type { Autore } from '@/collections/autori';
6051
- import type { Libro } from '@/collections/libri';
6052
-
6053
- export type SectionComponentPropsMap = {
6054
- 'authors-list': { data: AuthorsListData; settings?: AuthorsListSettings };
6055
- 'book-detail': { data: BookDetailData; settings?: BookDetailSettings };
6056
- 'books-list': { data: BooksListData; settings?: BooksListSettings };
6057
- 'empty-tenant': { data?: EmptyTenantData; settings?: EmptyTenantSettings };
6058
- footer: { data: FooterData; settings?: FooterSettings };
6059
- header: { data: HeaderData; settings?: HeaderSettings };
6060
- 'form-demo': { data: FormDemoData; settings?: FormDemoSettings };
6061
- };
6062
-
6063
- declare module '@olonjs/core' {
6064
- export interface SectionDataRegistry {
6065
- 'authors-list': AuthorsListData;
6066
- 'book-detail': BookDetailData;
6067
- 'books-list': BooksListData;
6068
- 'empty-tenant': EmptyTenantData;
6069
- footer: FooterData;
6070
- header: HeaderData;
6071
- 'form-demo': FormDemoData;
6072
- }
6073
- export interface SectionSettingsRegistry {
6074
- 'authors-list': AuthorsListSettings;
6075
- 'book-detail': BookDetailSettings;
6076
- 'books-list': BooksListSettings;
6077
- 'empty-tenant': EmptyTenantSettings;
6078
- footer: FooterSettings;
6079
- header: HeaderSettings;
6080
- 'form-demo': FormDemoSettings;
6081
- }
6082
- export interface CollectionItemRegistry {
6083
- autori: Autore;
6084
- libri: Libro;
6085
- }
6086
- }
6087
-
6088
- export * from '@olonjs/core';
7211
+ import type { AuthorsListData, AuthorsListSettings } from '@/components/authors-list';
7212
+ import type { BookDetailData, BookDetailSettings } from '@/components/book-detail';
7213
+ import type { BooksListData, BooksListSettings } from '@/components/books-list';
7214
+ import type { EmptyTenantData, EmptyTenantSettings } from '@/components/empty-tenant';
7215
+ import type { FooterData, FooterSettings } from '@/components/footer';
7216
+ import type { FormDemoData, FormDemoSettings } from '@/components/form-demo';
7217
+ import type { HeaderData, HeaderSettings } from '@/components/header';
7218
+ import type { ProdottoDetailData, ProdottoDetailSettings } from '@/components/prodotto-detail';
7219
+ import type { ProdottiListData, ProdottiListSettings } from '@/components/prodotti-list';
7220
+ import type { Autore } from '@/collections/autori';
7221
+ import type { Libro } from '@/collections/libri';
7222
+ import type { Prodotto } from '@/collections/prodotti';
7223
+
7224
+ export type SectionComponentPropsMap = {
7225
+ 'authors-list': { data: AuthorsListData; settings?: AuthorsListSettings };
7226
+ 'book-detail': { data: BookDetailData; settings?: BookDetailSettings };
7227
+ 'books-list': { data: BooksListData; settings?: BooksListSettings };
7228
+ 'empty-tenant': { data?: EmptyTenantData; settings?: EmptyTenantSettings };
7229
+ footer: { data: FooterData; settings?: FooterSettings };
7230
+ header: { data: HeaderData; settings?: HeaderSettings };
7231
+ 'form-demo': { data: FormDemoData; settings?: FormDemoSettings };
7232
+ 'prodotti-list': { data: ProdottiListData; settings?: ProdottiListSettings };
7233
+ 'prodotto-detail': { data: ProdottoDetailData; settings?: ProdottoDetailSettings };
7234
+ };
7235
+
7236
+ declare module '@olonjs/core' {
7237
+ export interface SectionDataRegistry {
7238
+ 'authors-list': AuthorsListData;
7239
+ 'book-detail': BookDetailData;
7240
+ 'books-list': BooksListData;
7241
+ 'empty-tenant': EmptyTenantData;
7242
+ footer: FooterData;
7243
+ header: HeaderData;
7244
+ 'form-demo': FormDemoData;
7245
+ 'prodotti-list': ProdottiListData;
7246
+ 'prodotto-detail': ProdottoDetailData;
7247
+ }
7248
+ export interface SectionSettingsRegistry {
7249
+ 'authors-list': AuthorsListSettings;
7250
+ 'book-detail': BookDetailSettings;
7251
+ 'books-list': BooksListSettings;
7252
+ 'empty-tenant': EmptyTenantSettings;
7253
+ footer: FooterSettings;
7254
+ header: HeaderSettings;
7255
+ 'form-demo': FormDemoSettings;
7256
+ 'prodotti-list': ProdottiListSettings;
7257
+ 'prodotto-detail': ProdottoDetailSettings;
7258
+ }
7259
+ export interface CollectionItemRegistry {
7260
+ autori: Autore;
7261
+ libri: Libro;
7262
+ prodotti: Prodotto;
7263
+ }
7264
+ }
7265
+
7266
+ export * from '@olonjs/core';
6089
7267
 
6090
7268
  END_OF_FILE_CONTENT
6091
7269
  mkdir -p "templates"
@@ -7394,7 +8572,7 @@ export const ContactFormSchema = BaseSectionData.merge(WithFormRecipient).extend
7394
8572
  });
7395
8573
  export const ContactFormSubmissionSchema = z.object({
7396
8574
  name: z.string().min(1).describe('Full name'),
7397
- email: z.string().email().describe('Reply email'),
8575
+ email: z.email().describe('Reply email'),
7398
8576
  message: z.string().min(1).describe('Message body'),
7399
8577
  });
7400
8578
  EOF
@@ -11368,6 +12546,8 @@ import type { TagPostsData, TagPostsSettings } from '@/components/tag-posts';
11368
12546
  import type { ContentBlockData, ContentBlockSettings } from '@/components/content-block';
11369
12547
  import type { StatsBandData, StatsBandSettings } from '@/components/stats-band';
11370
12548
  import type { CtaBannerData, CtaBannerSettings } from '@/components/cta-banner';
12549
+ import type { Post } from '@/collections/posts';
12550
+ import type { Tag } from '@/collections/tags';
11371
12551
 
11372
12552
  export type SectionComponentPropsMap = {
11373
12553
  'header': { data: HeaderData; settings: HeaderSettings };
@@ -11416,6 +12596,10 @@ declare module '@olonjs/core' {
11416
12596
  'stats-band': StatsBandSettings;
11417
12597
  'cta-banner': CtaBannerSettings;
11418
12598
  }
12599
+ export interface CollectionItemRegistry {
12600
+ posts: Post;
12601
+ tags: Tag;
12602
+ }
11419
12603
  }
11420
12604
 
11421
12605
  export * from '@olonjs/core';