@olonjs/cli 3.0.166 → 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.
@@ -1817,9 +1817,9 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1817
1817
  "@tiptap/extension-link": "^2.11.5",
1818
1818
  "@tiptap/react": "^2.11.5",
1819
1819
  "@tiptap/starter-kit": "^2.11.5",
1820
- "@olonjs/core": "^2.0.2",
1821
- "@olonjs/react": "^0.2.2",
1822
- "@olonjs/studio": "^0.2.2",
1820
+ "@olonjs/core": "^2.0.3",
1821
+ "@olonjs/react": "^0.2.3",
1822
+ "@olonjs/studio": "^0.2.3",
1823
1823
  "class-variance-authority": "^0.7.1",
1824
1824
  "clsx": "^2.1.1",
1825
1825
  "lucide-react": "^0.474.0",
@@ -1817,9 +1817,9 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1817
1817
  "@tiptap/extension-link": "^2.11.5",
1818
1818
  "@tiptap/react": "^2.11.5",
1819
1819
  "@tiptap/starter-kit": "^2.11.5",
1820
- "@olonjs/core": "^2.0.2",
1821
- "@olonjs/react": "^0.2.2",
1822
- "@olonjs/studio": "^0.2.2",
1820
+ "@olonjs/core": "^2.0.3",
1821
+ "@olonjs/react": "^0.2.3",
1822
+ "@olonjs/studio": "^0.2.3",
1823
1823
  "class-variance-authority": "^0.7.1",
1824
1824
  "clsx": "^2.1.1",
1825
1825
  "lucide-react": "^0.474.0",
@@ -24,6 +24,8 @@ import { getFilePages } from '@/lib/loaders/getFilePages';
24
24
  import { getFileSiteBundle } from '@/lib/loaders/getFileSiteConfig';
25
25
  import { loadVisitorPageBundle } from '@/lib/loaders/loadVisitorPageBundle';
26
26
  import { resolveVisitorShell } from '@/lib/resolveVisitorShell';
27
+ import { resolvePublicToolNames } from '@/lib/webmcp/resolvePublicToolNames';
28
+ import { sectionPublicTools } from '@/lib/webmcp/toolRegistry';
27
29
  import { VisitorSection } from '@/lib/VisitorSection';
28
30
 
29
31
  export const dynamic = 'force-dynamic';
@@ -115,6 +117,7 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
115
117
  const pageNum = Number(firstSearchValue(query.page) ?? '1') || 1;
116
118
  const shell = resolveVisitorShell(page, resolved.siteConfig ?? siteConfig);
117
119
  const sectionExtras = { authorId, page: pageNum, pathname };
120
+ const publicToolNames = resolvePublicToolNames(page.sections, sectionPublicTools as Record<string, readonly string[]>);
118
121
  const title = page.meta?.title ?? result.registrySlug;
119
122
  const description = typeof page.meta?.description === 'string' ? page.meta.description : '';
120
123
  const jsonLd = buildVisitorWebPageJsonLd({
@@ -125,7 +128,7 @@ export default async function VisitorCatchAllPage({ params, searchParams }: Page
125
128
 
126
129
  return (
127
130
  <>
128
- <WebMcpVisitorRuntime />
131
+ <WebMcpVisitorRuntime toolNames={publicToolNames} />
129
132
  <link rel="mcp-manifest" href={buildPageManifestHref(requestSlug)} />
130
133
  <link rel="olon-contract" href={buildPageContractHref(requestSlug)} />
131
134
  <script
@@ -786,10 +789,11 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
786
789
  "dist:dna": "npm run dist"
787
790
  },
788
791
  "dependencies": {
789
- "@olonjs/core": "^2.0.2",
790
- "@olonjs/next": "^0.0.14",
791
- "@olonjs/react": "^0.2.2",
792
- "@olonjs/studio": "^0.2.2",
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",
793
797
  "clsx": "^2.1.1",
794
798
  "lucide-react": "^0.474.0",
795
799
  "next": "^15.5.0",
@@ -797,14 +801,14 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
797
801
  "react-dom": "^19.0.0",
798
802
  "react-router-dom": "^6.29.0",
799
803
  "tailwind-merge": "^3.0.1",
800
- "zod": "^4.6.0",
801
- "@olonjs/sources": "^0.1.0"
804
+ "zod": "^4.6.0"
802
805
  },
803
806
  "devDependencies": {
804
807
  "@tailwindcss/postcss": "^4.0.0",
805
808
  "@types/node": "^22.13.1",
806
809
  "@types/react": "^19.0.0",
807
810
  "@types/react-dom": "^19.0.0",
811
+ "jsdom": "^26.1.0",
808
812
  "tailwindcss": "^4.0.0",
809
813
  "typescript": "^5.7.3",
810
814
  "vitest": "^3.0.0"
@@ -1538,6 +1542,7 @@ import { ComponentRegistry } from '@/lib/ComponentRegistry';
1538
1542
  import { iconMap } from '@/lib/IconResolver';
1539
1543
  import { SECTION_SCHEMAS } from '@/lib/schemas';
1540
1544
  import { buildEnabledWebMcpConfig } from '@/lib/webmcp/buildEnabledWebMcpConfig';
1545
+ import { adminToolExecutors, declaredAdminTools } from '@/lib/webmcp/toolRegistry';
1541
1546
 
1542
1547
  export type AdminStudioClientProps = {
1543
1548
  tenantId?: string;
@@ -1625,7 +1630,11 @@ export function AdminStudioClient({
1625
1630
  themeCss: { tenant: '' },
1626
1631
  iconRegistry: iconMap,
1627
1632
  addSection: addSectionConfig,
1628
- webmcp: buildEnabledWebMcpConfig(),
1633
+ webmcp: {
1634
+ ...buildEnabledWebMcpConfig(),
1635
+ tools: declaredAdminTools,
1636
+ toolExecutors: adminToolExecutors,
1637
+ },
1629
1638
  persistence: {
1630
1639
  saveToFile,
1631
1640
  ...(coldSave ? { coldSave } : {}),
@@ -2653,6 +2662,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/index.ts"
2653
2662
  export { FormDemoView } from './View';
2654
2663
  export { FormDemoClient } from './FormDemoClient';
2655
2664
  export { FormDemoSchema, FormDemoSettingsSchema, FormDemoSubmissionSchema } from './schema';
2665
+ export { webmcpTools } from './webmcpTools';
2656
2666
  export type { FormDemoData, FormDemoSettings } from './types';
2657
2667
 
2658
2668
  END_OF_FILE_CONTENT
@@ -2680,6 +2690,173 @@ export const FormDemoSubmissionSchema = z.object({
2680
2690
  message: z.string().min(1).describe('Free-form message body'),
2681
2691
  });
2682
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
+
2683
2860
  END_OF_FILE_CONTENT
2684
2861
  echo "Creating src/components/form-demo/types.ts..."
2685
2862
  cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/types.ts"
@@ -2689,6 +2866,27 @@ import { FormDemoSchema, FormDemoSettingsSchema } from './schema';
2689
2866
  export type FormDemoData = z.infer<typeof FormDemoSchema>;
2690
2867
  export type FormDemoSettings = z.infer<typeof FormDemoSettingsSchema>;
2691
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
+
2692
2890
  END_OF_FILE_CONTENT
2693
2891
  mkdir -p "src/components/header"
2694
2892
  echo "Creating src/components/header/View.tsx..."
@@ -2963,16 +3161,38 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/webmcp/WebMcpVisitorRuntime.tsx"
2963
3161
  'use client';
2964
3162
 
2965
3163
  import { useEffect } from 'react';
3164
+ import { registerWebMcpTool } from '@olonjs/core';
2966
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';
2967
3169
 
2968
3170
  /**
2969
- * Client island: WebMCP imperative API on public pages (readResource / listTools empty).
2970
- * 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).
2971
3177
  */
2972
- export function WebMcpVisitorRuntime() {
3178
+ export function WebMcpVisitorRuntime({ toolNames = [] }: { toolNames?: string[] }) {
2973
3179
  useEffect(() => {
2974
3180
  bootstrapVisitorWebMcpRuntime();
2975
- }, []);
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]);
2976
3196
 
2977
3197
  return null;
2978
3198
  }
@@ -5966,6 +6186,66 @@ export function buildEnabledWebMcpConfig(namespace?: string): {
5966
6186
  };
5967
6187
  }
5968
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
+
5969
6249
  END_OF_FILE_CONTENT
5970
6250
  mkdir -p "src/lib/webmcp/runtime"
5971
6251
  echo "Creating src/lib/webmcp/runtime/agenticSurface.test.ts..."
@@ -6123,6 +6403,36 @@ describe('robots + sitemap', () => {
6123
6403
  expect(xml).toContain('<lastmod>2026-09-10T10:00:00Z</lastmod>');
6124
6404
  });
6125
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
+ });
6126
6436
 
6127
6437
  END_OF_FILE_CONTENT
6128
6438
  echo "Creating src/lib/webmcp/runtime/agenticSurface.ts..."
@@ -6141,6 +6451,7 @@ import {
6141
6451
  webmcp,
6142
6452
  type JsonPagesConfig,
6143
6453
  type PageConfig,
6454
+ type WebMcpToolDef,
6144
6455
  } from '@olonjs/core';
6145
6456
  import type { PublicPageContentBundle } from '@olonjs/next/server';
6146
6457
 
@@ -6157,6 +6468,8 @@ export type RuntimeSurfaceInput = {
6157
6468
  bundle: PublicPageContentBundle;
6158
6469
  schemas: JsonPagesConfig['schemas'];
6159
6470
  submissionSchemas?: JsonPagesConfig['submissionSchemas'];
6471
+ /** Tenant-declared tools (public surface), appended to the contract's built-ins. */
6472
+ declaredTools?: readonly WebMcpToolDef[];
6160
6473
  };
6161
6474
 
6162
6475
  function joinSegments(segments: readonly string[]): string {
@@ -6245,6 +6558,7 @@ export function buildRuntimeSiteManifest(input: RuntimeSurfaceInput) {
6245
6558
  siteConfig: input.bundle.siteConfig,
6246
6559
  // Bundle carries SchemaLike; the manifest builder expects the Zod registry (same objects).
6247
6560
  collectionSchemas: input.bundle.collectionSchemas as never,
6561
+ declaredTools: input.declaredTools,
6248
6562
  });
6249
6563
  }
6250
6564
 
@@ -6257,6 +6571,7 @@ export function buildRuntimePageManifest(input: RuntimeSurfaceInput & { slug: st
6257
6571
  schemas: input.schemas,
6258
6572
  submissionSchemas: input.submissionSchemas,
6259
6573
  siteConfig: input.bundle.siteConfig,
6574
+ declaredTools: input.declaredTools,
6260
6575
  });
6261
6576
  }
6262
6577
 
@@ -6269,6 +6584,7 @@ export function buildRuntimePageContract(input: RuntimeSurfaceInput & { slug: st
6269
6584
  schemas: input.schemas,
6270
6585
  submissionSchemas: input.submissionSchemas,
6271
6586
  siteConfig: input.bundle.siteConfig,
6587
+ declaredTools: input.declaredTools,
6272
6588
  });
6273
6589
  }
6274
6590
 
@@ -6287,6 +6603,7 @@ export function buildRuntimeLlmsTxt(input: RuntimeSurfaceInput): string {
6287
6603
  schemas: input.schemas,
6288
6604
  submissionSchemas: input.submissionSchemas,
6289
6605
  siteConfig: input.bundle.siteConfig,
6606
+ declaredTools: input.declaredTools,
6290
6607
  });
6291
6608
  }
6292
6609
 
@@ -6396,6 +6713,7 @@ import { readServerCloudPolicy } from '@/lib/env/serverCloudPolicy';
6396
6713
  import { loadLocalPublicPageBundle } from '@/lib/loaders/loadLocalPublicPageBundle';
6397
6714
  import { loadPublicPageBundleForRequest } from '@/lib/loaders/loadPublicPageBundleForRequest';
6398
6715
  import { SECTION_SCHEMAS, SECTION_SUBMISSION_SCHEMAS } from '@/lib/schemas';
6716
+ import { declaredPublicTools } from '@/lib/webmcp/toolRegistry';
6399
6717
  import type { RuntimeSurfaceInput } from './agenticSurface';
6400
6718
 
6401
6719
  const schemas = SECTION_SCHEMAS as unknown as JsonPagesConfig['schemas'];
@@ -6418,7 +6736,7 @@ export async function loadRuntimeSurfaceForSlug(input: {
6418
6736
  apiUrl: policy.apiUrl,
6419
6737
  apiKey: policy.apiKey,
6420
6738
  });
6421
- return { bundle, schemas, submissionSchemas };
6739
+ return { bundle, schemas, submissionSchemas, declaredTools: declaredPublicTools };
6422
6740
  }
6423
6741
 
6424
6742
  /**
@@ -6427,7 +6745,7 @@ export async function loadRuntimeSurfaceForSlug(input: {
6427
6745
  */
6428
6746
  export function loadRuntimeSurfaceForSite(): RuntimeSurfaceInput {
6429
6747
  const bundle = loadLocalPublicPageBundle(path.resolve(process.cwd()));
6430
- return { bundle, schemas, submissionSchemas };
6748
+ return { bundle, schemas, submissionSchemas, declaredTools: declaredPublicTools };
6431
6749
  }
6432
6750
 
6433
6751
  /** Public base URL for absolute links (robots/sitemap). */
@@ -6487,6 +6805,236 @@ describe('next.config rewrites — runtime agentic surface', () => {
6487
6805
  });
6488
6806
  });
6489
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
+
6490
7038
  END_OF_FILE_CONTENT
6491
7039
  echo "Creating src/lib/webmcp/visitorPageWebMcpWiring.test.ts..."
6492
7040
  cat << 'END_OF_FILE_CONTENT' > "src/lib/webmcp/visitorPageWebMcpWiring.test.ts"
@@ -6506,6 +7054,28 @@ describe('visitor page WebMCP wiring', () => {
6506
7054
  });
6507
7055
  });
6508
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
+
6509
7079
  END_OF_FILE_CONTENT
6510
7080
  mkdir -p "src/sources"
6511
7081
  echo "Creating src/sources/catalogo.test.ts..."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olonjs/cli",
3
- "version": "3.0.166",
3
+ "version": "3.0.167",
4
4
  "description": "The Sovereign CLI Engine for OlonJS.",
5
5
  "type": "module",
6
6
  "bin": {