diff --git a/entities/parameter/lib/__tests__/parametersFilter.test.ts b/entities/parameter/lib/__tests__/parametersFilter.test.ts new file mode 100644 index 00000000..91396cf7 --- /dev/null +++ b/entities/parameter/lib/__tests__/parametersFilter.test.ts @@ -0,0 +1,140 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {NotificationAction} from "@AppBuilderLib/features/notifications/config/notificationcontext"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import { + filterAndValidateParameters, + generateParameterFeedback, +} from "../parametersFilter"; +function createMockParameter( + overrides: Partial> & { + definition: IShapeDiverParameter["definition"]; + }, +): IShapeDiverParameter { + return { + state: { + uiValue: overrides.state?.uiValue, + execValue: overrides.state?.execValue, + dirty: false, + disableOtherParameters: false, + stringExecValue: () => "", + }, + actions: { + setUiValue: () => true, + setUiAndExecValue: () => true, + execute: async () => "", + isValid: overrides.actions?.isValid ?? (() => true), + isUiValueDifferent: () => false, + resetToDefaultValue: () => undefined, + resetToExecValue: () => undefined, + }, + acceptRejectMode: false, + ...overrides, + } as IShapeDiverParameter; +} + +describe("filterAndValidateParameters", () => { + const widthParam = createMockParameter({ + definition: { + id: "width", + name: "Width", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + defval: 10, + } as IShapeDiverParameter["definition"], + }); + + it("reports unknown parameter in invalidParameters", () => { + const result = filterAndValidateParameters( + [widthParam], + [{id: "missing", value: 1}], + ); + + expect(result.hasValidParameters).toBe(false); + expect(result.skippedParameters).toEqual(["missing"]); + expect(result.invalidParameters).toEqual([ + { + name: "missing", + message: + 'Parameter "missing" does not exist in the current model session.', + }, + ]); + }); + + it("reports invalid value in invalidParameters", () => { + const result = filterAndValidateParameters( + [ + createMockParameter({ + definition: widthParam.definition, + actions: {isValid: () => false}, + }), + ], + [{id: "width", value: 999}], + ); + + expect(result.hasValidParameters).toBe(false); + expect(result.skippedParameters).toEqual(["width"]); + expect(result.invalidParameters).toEqual([ + { + name: "width", + message: 'Value is not valid for parameter "width"', + }, + ]); + }); + + it("keeps valid parameters and reports only failures", () => { + const result = filterAndValidateParameters( + [widthParam], + [ + {id: "width", value: 42}, + {id: "unknown", value: 1}, + ], + ); + + expect(result.hasValidParameters).toBe(true); + expect(result.validParameters).toEqual({width: 42}); + expect(result.invalidParameters).toEqual([ + { + name: "unknown", + message: + 'Parameter "unknown" does not exist in the current model session.', + }, + ]); + }); +}); + +describe("generateParameterFeedback", () => { + it("uses invalidParameters messages for partial success warnings", () => { + const feedback = generateParameterFeedback({ + validParameters: {width: 42}, + skippedParameters: ["unknown"], + invalidParameters: [ + { + name: "unknown", + message: + 'Parameter "unknown" does not exist in the current model session.', + }, + ], + hasValidParameters: true, + }); + + expect(feedback.type).toBe(NotificationAction.WARNING); + expect(feedback.message).toBe( + 'Parameter "unknown" does not exist in the current model session.', + ); + }); + + it("falls back to skipped parameter names when invalidParameters is empty", () => { + const feedback = generateParameterFeedback({ + validParameters: {width: 42}, + skippedParameters: ["foo", "bar"], + invalidParameters: [], + hasValidParameters: true, + }); + + expect(feedback.type).toBe(NotificationAction.WARNING); + expect(feedback.message).toBe( + "The following parameters could not be matched or ar invalid: foo, bar", + ); + }); +}); \ No newline at end of file diff --git a/entities/parameter/lib/parametersFilter.ts b/entities/parameter/lib/parametersFilter.ts index 25c0482f..e1b98ffd 100644 --- a/entities/parameter/lib/parametersFilter.ts +++ b/entities/parameter/lib/parametersFilter.ts @@ -2,11 +2,18 @@ import {NotificationAction} from "@AppBuilderLib/features/notifications/config/n import {z} from "@AppBuilderLib/shared/lib/zod"; import {IShapeDiverParameter} from "../config/parameter"; +export interface ParameterInvalidParameter { + name: string; + message: string; +} + export interface ParameterValidationResult { /** Validated (id,value) pairs of parameters */ validParameters: {[key: string]: any}; /** Names or IDs of parameters present in the input that could not be matched */ skippedParameters: string[]; + /** Per-parameter validation failures with user-facing messages */ + invalidParameters: ParameterInvalidParameter[]; /** Whether any valid parameters were found */ hasValidParameters: boolean; } @@ -44,6 +51,7 @@ export function filterAndValidateParameters( ): ParameterValidationResult { const validParameters: {[key: string]: any} = {}; const skippedParameters: string[] = []; + const invalidParameters: ParameterInvalidParameter[] = []; for (const param of parameterArray) { // Skip parameters without id or value @@ -62,13 +70,23 @@ export function filterAndValidateParameters( } if (!paramState) { - skippedParameters.push(param.name || param.id); + const name = param.name || param.id; + skippedParameters.push(name); + invalidParameters.push({ + name, + message: `Parameter "${name}" does not exist in the current model session.`, + }); continue; } // Validate parameter value if (!paramState.actions.isValid(param.value)) { - skippedParameters.push(param.name || param.id); + const name = param.name || param.id; + skippedParameters.push(name); + invalidParameters.push({ + name, + message: `Value is not valid for parameter "${name}"`, + }); continue; } @@ -78,6 +96,7 @@ export function filterAndValidateParameters( return { validParameters, skippedParameters: skippedParameters, + invalidParameters, hasValidParameters: Object.keys(validParameters).length > 0, }; } @@ -125,9 +144,14 @@ export function generateParameterFeedback( } if (result.skippedParameters.length > 0) { + const message = + result.invalidParameters.length > 0 + ? result.invalidParameters.map((p) => p.message).join(" ") + : `The following parameters could not be matched or ar invalid: ${result.skippedParameters.join(", ")}`; + return { type: NotificationAction.WARNING, - message: `The following parameters could not be matched or ar invalid: ${result.skippedParameters.join(", ")}`, + message, }; } diff --git a/features/appbuilder/config/appbuildertypecheck.ts b/features/appbuilder/config/appbuildertypecheck.ts index d7e58b79..5e6ddf55 100644 --- a/features/appbuilder/config/appbuildertypecheck.ts +++ b/features/appbuilder/config/appbuildertypecheck.ts @@ -1,4 +1,5 @@ import {filterableDatabaseSettingsSchema} from "@AppBuilderLib/entities/parameter/lib/filterableDatabase/filterableDatabaseSettingsSchema"; +import {createModelStateCoreSchema} from "@AppBuilderLib/features/model-state/config/createModelState.zod"; import {prettifyError, z} from "@AppBuilderLib/shared/lib/zod"; import {appBuilderThemeOtherPropsSchema} from "@AppBuilderLib/shared/mantine-props/appBuilderThemeOther.zod"; import {mantineThemeOverridePropsSchema} from "@AppBuilderLib/shared/mantine-props/themeOverride.zod"; @@ -391,15 +392,12 @@ const IAppBuilderParameterValueSourcePropsSdtfSchema = z.strictObject({ }); // Zod type definition for IAppBuilderActionPropsCreateModelState -const IAppBuilderActionPropsCreateModelStateSchema = z.strictObject({ - includeImage: z.boolean().optional(), - image: IAppBuilderImageRefSchema.optional(), - includeGltf: z.boolean().optional(), - parameterNamesToInclude: z.array(z.string()).optional(), - parameterNamesToExclude: z.array(z.string()).optional(), - successMessage: z.string().optional(), - errorMessage: z.string().optional(), -}); +const IAppBuilderActionPropsCreateModelStateSchema = + createModelStateCoreSchema.extend({ + image: IAppBuilderImageRefSchema.optional(), + successMessage: z.string().optional(), + errorMessage: z.string().optional(), + }); // Zod type definition for IAppBuilderParameterValueSourcePropsModelState const IAppBuilderParameterValueSourcePropsModelStateSchema = diff --git a/features/ecommerce/config/ecommerceapitypecheck.ts b/features/ecommerce/config/ecommerceapitypecheck.ts index 68210afa..1c62fba6 100644 --- a/features/ecommerce/config/ecommerceapitypecheck.ts +++ b/features/ecommerce/config/ecommerceapitypecheck.ts @@ -1,24 +1,16 @@ -import z from "@AppBuilderLib/shared/lib/zod"; -import {IAppBuilderImageRefSchema} from "@AppBuilderShared/features/appbuilder/config/appbuildertypecheck"; +import {createModelStateDataSchema} from "@AppBuilderLib/features/model-state/config/createModelState.zod"; +import {importModelStateDataSchema} from "@AppBuilderLib/features/model-state/config/importModelState.zod"; +import z from "zod"; // Zod type definition for ICreateModelStateData -export const ICreateModelStateDataSchema = z.object({ - parameterNamesToInclude: z.array(z.string()).optional(), - parameterNamesToExclude: z.array(z.string()).optional(), - includeImage: z.boolean().optional(), - image: IAppBuilderImageRefSchema.optional(), - data: z.record(z.string(), z.any()).optional(), - includeGltf: z.boolean().optional(), -}); +export const ICreateModelStateDataSchema = createModelStateDataSchema; export const validateCreateModelStateData = (value: any) => { return ICreateModelStateDataSchema.safeParse(value); }; // Zod type definition for IImportModelStateData -export const IImportModelStateDataSchema = z.object({ - modelStateId: z.string(), -}); +export const IImportModelStateDataSchema = importModelStateDataSchema; export const validateImportModelStateData = (value: any) => { return IImportModelStateDataSchema.safeParse(value); diff --git a/features/model-state/config/createModelState.ts b/features/model-state/config/createModelState.ts index 64c6bec7..d118a116 100644 --- a/features/model-state/config/createModelState.ts +++ b/features/model-state/config/createModelState.ts @@ -1,49 +1,10 @@ -import {ResExportDefinition} from "@shapediver/sdk.geometry-api-sdk-v2"; - -/** - * Reference to an export (defined by the session) - * (duplicate of IAppBuilderExportRef to avoid importing mantine from here) - */ -interface IExportRef { - /** Id or name or displayname of the referenced export (in that order). */ - name: string; - /** Optional id of the session the referenced parameter belongs to. */ - sessionId?: string; - /** Properties of the export to be overridden. */ - overrides?: Pick< - Partial, - "displayname" | "group" | "order" | "tooltip" | "hidden" - >; -} - -/** - * Reference to an image - * (duplicate of IAppBuilderImageRef to avoid importing mantine from here) - */ -interface IImageRef { - /** Optional reference to export which provides the image. */ - export?: Pick; - /** URL to image. Can be a data URL including a base 64 encoded image. Takes precedence over export reference. */ - href?: string; -} +import type {z} from "zod"; +import {createModelStateDataSchema} from "./createModelState.zod"; /** * Data accepted by the useCreateModelState hook to create a model state. */ -export interface ICreateModelStateData { - /** Optional list of parameter ids/names to include. */ - parameterNamesToInclude?: string[]; - /** Optional list of parameter names to exclude. */ - parameterNamesToExclude?: string[]; - /** Whether to include an image. */ - includeImage?: boolean; - /** Optional image definition. If undefined, a screenshot will be used. */ - image?: IImageRef | undefined; - /** Optional data to include with the saved model state. */ - data?: Record; - /** Whether to save a glTF of the scene. */ - includeGltf?: boolean; -} +export type ICreateModelStateData = z.infer; /** * Data returned from the useCreateModelState hook. diff --git a/features/model-state/config/createModelState.zod.ts b/features/model-state/config/createModelState.zod.ts new file mode 100644 index 00000000..52618630 --- /dev/null +++ b/features/model-state/config/createModelState.zod.ts @@ -0,0 +1,40 @@ +import {z} from "zod"; + +export const createModelStateImageRefSchema = z.strictObject({ + export: z + .strictObject({ + name: z.string(), + sessionId: z.string().optional(), + }) + .optional(), + href: z.string().optional(), +}); + +export const createModelStateCoreSchema = z.strictObject({ + parameterNamesToInclude: z + .array(z.string()) + .optional() + .describe("Only include these parameters in the saved state."), + parameterNamesToExclude: z + .array(z.string()) + .optional() + .describe("Exclude these parameters from the saved state."), + includeImage: z + .boolean() + .optional() + .describe( + "Whether to include a preview image. Use false when not needed.", + ), + includeGltf: z + .boolean() + .optional() + .describe("Whether to include an exported GLTF asset."), +}); + +export const createModelStateDataSchema = createModelStateCoreSchema.extend({ + image: createModelStateImageRefSchema.optional(), + data: z + .record(z.string(), z.any()) + .optional() + .describe("Optional custom metadata to store with the state."), +}); diff --git a/features/model-state/config/importModelState.ts b/features/model-state/config/importModelState.ts index 0f6532a3..a8f48ba6 100644 --- a/features/model-state/config/importModelState.ts +++ b/features/model-state/config/importModelState.ts @@ -1,12 +1,16 @@ import {ResGetModelState} from "@shapediver/sdk.geometry-api-sdk-v2"; +import type {z} from "zod"; +import { + importModelStateDataSchema, + nameMessageSchema, +} from "./importModelState.zod"; /** * Data accepted by the useImportModelState hook to import a model state. */ -export interface IImportModelStateData { - /** Id of the model state to import. */ - modelStateId: string; -} +export type IImportModelStateData = z.infer; + +type NameMessage = z.infer; /** * Data returned from the useImportModelState hook. @@ -15,8 +19,10 @@ export type IImportModelStateResult = | { success: false; message: string; + invalidParameters?: NameMessage[]; } | { success: true; data: ResGetModelState; + invalidParameters?: NameMessage[]; }; diff --git a/features/model-state/config/importModelState.zod.ts b/features/model-state/config/importModelState.zod.ts new file mode 100644 index 00000000..28396bcc --- /dev/null +++ b/features/model-state/config/importModelState.zod.ts @@ -0,0 +1,15 @@ +import {z} from "zod"; + +/** Shared `{ name, message }` for validation / tool errors. */ +export const nameMessageSchema = z.object({ + name: z.string(), + message: z.string(), +}); + +export const importModelStateDataSchema = z.strictObject({ + modelStateId: z + .string() + .describe( + "modelStateId from create_model_state, or a full model view URL containing modelStateId.", + ), +}); diff --git a/features/model-state/model/useImportModelState.ts b/features/model-state/model/useImportModelState.ts index 3d3a3711..7033bef9 100644 --- a/features/model-state/model/useImportModelState.ts +++ b/features/model-state/model/useImportModelState.ts @@ -112,6 +112,7 @@ export function useImportModelState({namespace}: Props) { return { success: false, message: feedback.message, + invalidParameters: validationResult.invalidParameters, }; } @@ -135,6 +136,12 @@ export function useImportModelState({namespace}: Props) { return { success: true, data: response.data, + ...(validationResult.skippedParameters.length > 0 + ? { + invalidParameters: + validationResult.invalidParameters, + } + : {}), }; }, [sessionApi, namespace], diff --git a/features/webmcp/__tests__/computeAppliedParameterIds.test.ts b/features/webmcp/__tests__/computeAppliedParameterIds.test.ts new file mode 100644 index 00000000..69c4ab9b --- /dev/null +++ b/features/webmcp/__tests__/computeAppliedParameterIds.test.ts @@ -0,0 +1,43 @@ +import {computeAppliedParameterIds} from "../lib/computeAppliedParameterIds"; + +describe("computeAppliedParameterIds", () => { + it("returns ids whose uiValue changed", () => { + const beforeValues = new Map([ + ["width", 10], + ["height", 20], + ["depth", 5], + ]); + + const applied = computeAppliedParameterIds(beforeValues, [ + {definition: {id: "width"}, state: {uiValue: 42}}, + {definition: {id: "height"}, state: {uiValue: 20}}, + {definition: {id: "depth"}, state: {uiValue: 8}}, + ]); + + expect(applied).toEqual(["width", "depth"]); + }); + + it("returns empty array when no values changed", () => { + const beforeValues = new Map([ + ["width", 10], + ["height", 20], + ]); + + const applied = computeAppliedParameterIds(beforeValues, [ + {definition: {id: "width"}, state: {uiValue: 10}}, + {definition: {id: "height"}, state: {uiValue: 20}}, + ]); + + expect(applied).toEqual([]); + }); + + it("treats missing before snapshot as a change", () => { + const beforeValues = new Map(); + + const applied = computeAppliedParameterIds(beforeValues, [ + {definition: {id: "width"}, state: {uiValue: 10}}, + ]); + + expect(applied).toEqual(["width"]); + }); +}); diff --git a/features/webmcp/__tests__/parameterDefinitionMapper.test.ts b/features/webmcp/__tests__/parameterDefinitionMapper.test.ts new file mode 100644 index 00000000..d8ff5ddd --- /dev/null +++ b/features/webmcp/__tests__/parameterDefinitionMapper.test.ts @@ -0,0 +1,173 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; + +function createMockParameter( + overrides: Partial> & { + definition: IShapeDiverParameter["definition"]; + }, +): IShapeDiverParameter { + return { + state: { + uiValue: overrides.state?.uiValue, + execValue: overrides.state?.execValue, + dirty: false, + disableOtherParameters: false, + stringExecValue: () => "", + }, + actions: { + setUiValue: () => true, + setUiAndExecValue: () => true, + execute: async () => "", + isValid: () => true, + isUiValueDifferent: () => false, + resetToDefaultValue: () => undefined, + resetToExecValue: () => undefined, + }, + acceptRejectMode: false, + ...overrides, + } as IShapeDiverParameter; +} + +describe("mapParameterDefinition", () => { + it("maps STRINGLIST with index current/default values", () => { + const param = createMockParameter({ + definition: { + id: "list-1", + name: "Material", + type: ResParameterType.STRINGLIST, + choices: ["Wood", "Metal"], + defval: 1, + } as IShapeDiverParameter["definition"], + state: {uiValue: 0} as IShapeDiverParameter["state"], + }); + + expect(mapParameterDefinition(param)).toEqual({ + id: "list-1", + name: "Material", + type: ResParameterType.STRINGLIST, + settable: true, + choices: ["Wood", "Metal"], + currentValue: 0, + defaultValue: 1, + }); + }); + + it("maps COLOR with decomposed values", () => { + const param = createMockParameter({ + definition: { + id: "color-1", + name: "Paint", + type: ResParameterType.COLOR, + defval: "0xff0000ff", + } as IShapeDiverParameter["definition"], + state: { + uiValue: "0x00ff00ff", + } as IShapeDiverParameter["state"], + }); + + expect(mapParameterDefinition(param)).toEqual({ + id: "color-1", + name: "Paint", + type: ResParameterType.COLOR, + settable: true, + currentValue: {red: 0, green: 255, blue: 0, alpha: 255}, + defaultValue: {red: 255, green: 0, blue: 0, alpha: 255}, + }); + }); + + it("maps FLOAT with min/max/decimalplaces", () => { + const param = createMockParameter({ + definition: { + id: "float-1", + name: "Width", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + decimalplaces: 2, + defval: 10, + } as IShapeDiverParameter["definition"], + state: {uiValue: 42.5} as IShapeDiverParameter["state"], + }); + + expect(mapParameterDefinition(param)).toEqual({ + id: "float-1", + name: "Width", + type: ResParameterType.FLOAT, + settable: true, + min: 0, + max: 100, + decimalplaces: 2, + currentValue: 42.5, + defaultValue: 10, + }); + }); + + it("maps BOOL", () => { + const param = createMockParameter({ + definition: { + id: "bool-1", + name: "Enabled", + type: ResParameterType.BOOL, + defval: false, + } as IShapeDiverParameter["definition"], + state: {uiValue: true} as IShapeDiverParameter["state"], + }); + + expect(mapParameterDefinition(param)).toEqual({ + id: "bool-1", + name: "Enabled", + type: ResParameterType.BOOL, + settable: true, + currentValue: true, + defaultValue: false, + }); + }); + + it("includes group and tooltip from definition", () => { + const param = createMockParameter({ + definition: { + id: "p1", + name: "width", + displayname: "Width", + type: ResParameterType.INT, + min: 0, + max: 10, + defval: 5, + group: {name: "Dimensions"}, + tooltip: "Model width", + } as IShapeDiverParameter["definition"], + state: {uiValue: 7} as IShapeDiverParameter["state"], + }); + + expect(mapParameterDefinition(param)).toEqual({ + id: "p1", + name: "Width", + type: ResParameterType.INT, + settable: true, + group: "Dimensions", + tooltip: "Model width", + min: 0, + max: 10, + currentValue: 7, + defaultValue: 5, + }); + }); + + it("marks unsupported types as not settable", () => { + const param = createMockParameter({ + definition: { + id: "file-1", + name: "Upload", + type: ResParameterType.FILE, + } as IShapeDiverParameter["definition"], + }); + + expect(mapParameterDefinition(param)).toEqual({ + id: "file-1", + name: "Upload", + type: ResParameterType.FILE, + settable: false, + }); + }); +}); diff --git a/features/webmcp/__tests__/setParameterValues.feedback.test.ts b/features/webmcp/__tests__/setParameterValues.feedback.test.ts new file mode 100644 index 00000000..b024099a --- /dev/null +++ b/features/webmcp/__tests__/setParameterValues.feedback.test.ts @@ -0,0 +1,354 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {composeSdColor} from "@AppBuilderLib/shared/lib/colors"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import {resolveAndUpdate} from "../lib/resolveSetParameterUpdates"; + +function createMockParameter( + overrides: Partial> & { + definition: IShapeDiverParameter["definition"]; + }, +): IShapeDiverParameter { + return { + state: { + uiValue: overrides.state?.uiValue, + execValue: overrides.state?.execValue, + dirty: false, + disableOtherParameters: false, + stringExecValue: () => "", + }, + actions: { + setUiValue: () => true, + setUiAndExecValue: () => true, + execute: async () => "", + isValid: overrides.actions?.isValid ?? (() => true), + isUiValueDifferent: () => false, + resetToDefaultValue: () => undefined, + resetToExecValue: () => undefined, + }, + acceptRejectMode: false, + ...overrides, + } as IShapeDiverParameter; +} + +describe("resolveAndUpdate", () => { + const namespace = "session-1"; + const batchParameterValueUpdate = jest.fn().mockResolvedValue(undefined); + + const getParametersFor = + (paramsByNamespace: Record[]>) => + (ns: string) => + paramsByNamespace[ns] ?? []; + + beforeEach(() => { + batchParameterValueUpdate.mockClear(); + }); + + it("returns error for unknown parameter", async () => { + const result = await resolveAndUpdate( + namespace, + getParametersFor({[namespace]: []}), + [{name: "Missing", value: 1}], + batchParameterValueUpdate, + ); + + expect(result).toEqual({ + applied: [], + errors: [ + { + name: "Missing", + message: + 'Parameter with id/name/displayname "Missing" does not exist.', + }, + ], + }); + expect(batchParameterValueUpdate).not.toHaveBeenCalled(); + }); + + it("applies valid update", async () => { + const param = createMockParameter({ + definition: { + id: "width", + name: "Width", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + defval: 10, + } as IShapeDiverParameter["definition"], + state: {uiValue: 10} as IShapeDiverParameter["state"], + }); + + const result = await resolveAndUpdate( + namespace, + getParametersFor({[namespace]: [param]}), + [{name: "Width", value: 42}], + batchParameterValueUpdate, + ); + + expect(result).toEqual({applied: ["width"], errors: []}); + expect(batchParameterValueUpdate).toHaveBeenCalledWith({ + [namespace]: {width: 42}, + }); + }); + + it("returns error for invalid numeric value", async () => { + const param = createMockParameter({ + definition: { + id: "width", + name: "Width", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + defval: 10, + } as IShapeDiverParameter["definition"], + state: {uiValue: 10} as IShapeDiverParameter["state"], + actions: { + isValid: () => false, + } as IShapeDiverParameter["actions"], + }); + + const result = await resolveAndUpdate( + namespace, + getParametersFor({[namespace]: [param]}), + [{name: "Width", value: 200}], + batchParameterValueUpdate, + ); + + expect(result).toEqual({ + applied: [], + errors: [ + { + name: "Width", + message: + 'Value 200 is not valid for parameter "Width" (Float). Use a number in range [0, 100].', + }, + ], + }); + expect(batchParameterValueUpdate).not.toHaveBeenCalled(); + }); + + it("returns error for duplicate parameter updates", async () => { + const param = createMockParameter({ + definition: { + id: "width", + name: "Width", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + defval: 10, + } as IShapeDiverParameter["definition"], + state: {uiValue: 10} as IShapeDiverParameter["state"], + }); + + const result = await resolveAndUpdate( + namespace, + getParametersFor({[namespace]: [param]}), + [ + {name: "Width", value: 20}, + {name: "width", value: 30}, + ], + batchParameterValueUpdate, + ); + + expect(result.errors).toEqual([ + { + name: "width", + message: 'Refusing to update parameter "width" twice.', + }, + ]); + expect(result.applied).toEqual(["width"]); + expect(batchParameterValueUpdate).toHaveBeenCalledWith({ + [namespace]: {width: 20}, + }); + }); + + it("composes COLOR object and applies when valid", async () => { + const composeSpy = jest.spyOn( + await import("@AppBuilderLib/shared/lib/colors"), + "composeSdColor", + ); + const colorValue = {red: 10, green: 20, blue: 30, alpha: 255}; + const composed = composeSdColor(colorValue); + + const param = createMockParameter({ + definition: { + id: "paint", + name: "Paint", + type: ResParameterType.COLOR, + defval: "0x000000ff", + } as IShapeDiverParameter["definition"], + state: { + uiValue: "0x000000ff", + } as IShapeDiverParameter["state"], + actions: { + isValid: (value: unknown) => value === composed, + } as IShapeDiverParameter["actions"], + }); + + const result = await resolveAndUpdate( + namespace, + getParametersFor({[namespace]: [param]}), + [{name: "Paint", value: colorValue}], + batchParameterValueUpdate, + ); + + expect(composeSpy).toHaveBeenCalledWith(colorValue); + expect(result).toEqual({applied: ["paint"], errors: []}); + expect(batchParameterValueUpdate).toHaveBeenCalledWith({ + [namespace]: {paint: composed}, + }); + + composeSpy.mockRestore(); + }); + + it("rejects color object for non-color parameter", async () => { + const param = createMockParameter({ + definition: { + id: "width", + name: "Width", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + defval: 10, + } as IShapeDiverParameter["definition"], + state: {uiValue: 10} as IShapeDiverParameter["state"], + }); + + const result = await resolveAndUpdate( + namespace, + getParametersFor({[namespace]: [param]}), + [ + { + name: "Width", + value: {red: 1, green: 2, blue: 3, alpha: 4}, + }, + ], + batchParameterValueUpdate, + ); + + expect(result).toEqual({ + applied: [], + errors: [ + { + name: "Width", + message: + "Color object value is only valid for Color parameters.", + }, + ], + }); + }); + + it("applies StringList index as number (stored as string)", async () => { + const isValid = jest.fn((value: unknown) => value === "1"); + const param = createMockParameter({ + definition: { + id: "material", + name: "Material", + type: ResParameterType.STRINGLIST, + choices: ["Wood", "Metal"], + defval: 0, + } as unknown as IShapeDiverParameter["definition"], + state: {uiValue: "0"} as IShapeDiverParameter["state"], + actions: {isValid} as IShapeDiverParameter["actions"], + }); + + const result = await resolveAndUpdate( + namespace, + getParametersFor({[namespace]: [param]}), + [{name: "Material", value: 1}], + batchParameterValueUpdate, + ); + + expect(isValid).toHaveBeenCalledWith("1", false); + expect(result).toEqual({applied: ["material"], errors: []}); + expect(batchParameterValueUpdate).toHaveBeenCalledWith({ + [namespace]: {material: "1"}, + }); + }); + + it("looks up parameters in update sessionId namespace", async () => { + const childNamespace = "child-session"; + const mainParam = createMockParameter({ + definition: { + id: "width", + name: "Width", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + defval: 10, + } as IShapeDiverParameter["definition"], + state: {uiValue: 10} as IShapeDiverParameter["state"], + }); + const childParam = createMockParameter({ + definition: { + id: "height", + name: "Height", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + defval: 20, + } as IShapeDiverParameter["definition"], + state: {uiValue: 20} as IShapeDiverParameter["state"], + }); + const getParameters = jest.fn((ns: string) => { + if (ns === namespace) { + return [mainParam]; + } + if (ns === childNamespace) { + return [childParam]; + } + + return []; + }); + + const result = await resolveAndUpdate( + namespace, + getParameters, + [{name: "Height", sessionId: childNamespace, value: 55}], + batchParameterValueUpdate, + ); + + expect(getParameters).toHaveBeenCalledWith(childNamespace); + expect(result).toEqual({applied: ["height"], errors: []}); + expect(batchParameterValueUpdate).toHaveBeenCalledWith({ + [childNamespace]: {height: 55}, + }); + }); + + it("reports missing parameter in target sessionId namespace", async () => { + const childNamespace = "child-session"; + const mainParam = createMockParameter({ + definition: { + id: "width", + name: "Width", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + defval: 10, + } as IShapeDiverParameter["definition"], + state: {uiValue: 10} as IShapeDiverParameter["state"], + }); + const getParameters = jest.fn((ns: string) => + ns === namespace ? [mainParam] : [], + ); + + const result = await resolveAndUpdate( + namespace, + getParameters, + [{name: "Width", sessionId: childNamespace, value: 42}], + batchParameterValueUpdate, + ); + + expect(getParameters).toHaveBeenCalledWith(childNamespace); + expect(result).toEqual({ + applied: [], + errors: [ + { + name: "Width", + message: + 'Parameter with id/name/displayname "Width" does not exist.', + }, + ], + }); + expect(batchParameterValueUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/features/webmcp/__tests__/webmcpAvailability.test.ts b/features/webmcp/__tests__/webmcpAvailability.test.ts new file mode 100644 index 00000000..836a6cec --- /dev/null +++ b/features/webmcp/__tests__/webmcpAvailability.test.ts @@ -0,0 +1,80 @@ +import { + getWebMcpEnvironment, + isCrossOriginIsolated, + isWebMcpAvailable, +} from "../lib/webmcpAvailability"; + +describe("isCrossOriginIsolated", () => { + const original = Object.getOwnPropertyDescriptor( + globalThis, + "crossOriginIsolated", + ); + + afterEach(() => { + if (original) { + Object.defineProperty(globalThis, "crossOriginIsolated", original); + } else { + Reflect.deleteProperty(globalThis, "crossOriginIsolated"); + } + }); + + it("returns false when crossOriginIsolated is undefined", () => { + Reflect.deleteProperty(globalThis, "crossOriginIsolated"); + expect(isCrossOriginIsolated()).toBe(false); + }); + + it("returns true when crossOriginIsolated is true", () => { + Object.defineProperty(globalThis, "crossOriginIsolated", { + value: true, + configurable: true, + }); + expect(isCrossOriginIsolated()).toBe(true); + }); + + it("returns false when crossOriginIsolated is false", () => { + Object.defineProperty(globalThis, "crossOriginIsolated", { + value: false, + configurable: true, + }); + expect(isCrossOriginIsolated()).toBe(false); + }); +}); + +describe("getWebMcpEnvironment", () => { + const original = Object.getOwnPropertyDescriptor( + globalThis, + "crossOriginIsolated", + ); + + afterEach(() => { + if (original) { + Object.defineProperty(globalThis, "crossOriginIsolated", original); + } else { + Reflect.deleteProperty(globalThis, "crossOriginIsolated"); + } + }); + + it("returns structure with ready = modelContextAvailable && crossOriginIsolated", () => { + Object.defineProperty(globalThis, "crossOriginIsolated", { + value: true, + configurable: true, + }); + + const env = getWebMcpEnvironment(); + + expect(env).toEqual({ + modelContextAvailable: isWebMcpAvailable(), + crossOriginIsolated: true, + ready: isWebMcpAvailable() && true, + }); + }); + + it("ready is false when cross-origin isolation is missing", () => { + Reflect.deleteProperty(globalThis, "crossOriginIsolated"); + + const env = getWebMcpEnvironment(); + + expect(env.crossOriginIsolated).toBe(false); + expect(env.ready).toBe(false); + }); +}); diff --git a/features/webmcp/__tests__/webmcpSchemas.test.ts b/features/webmcp/__tests__/webmcpSchemas.test.ts new file mode 100644 index 00000000..c5219160 --- /dev/null +++ b/features/webmcp/__tests__/webmcpSchemas.test.ts @@ -0,0 +1,197 @@ +import {createModelStateInputSchema} from "../config/createModelState"; +import { + importModelStateInputSchema, + importModelStateSuccessOutputSchema, +} from "../config/importModelState"; +import { + listParameterDefinitionsInputSchema, + listParameterDefinitionsOutputSchema, +} from "../config/listParameterDefinitions"; +import {setParameterValuesInputSchema} from "../config/setParameterValues"; +import {formatToolInputError} from "../lib/formatToolInputError"; + +describe("webmcp input schemas", () => { + describe("listParameterDefinitionsInputSchema", () => { + it("accepts valid input", () => { + expect( + listParameterDefinitionsInputSchema.parse({ + filter: "visible", + sessionId: "session-1", + }), + ).toEqual({ + filter: "visible", + sessionId: "session-1", + }); + }); + + it("accepts empty object with defaults implied at runtime", () => { + expect(listParameterDefinitionsInputSchema.parse({})).toEqual({}); + }); + + it("rejects invalid filter enum", () => { + expect(() => + listParameterDefinitionsInputSchema.parse({filter: "hidden"}), + ).toThrow(); + }); + + it("rejects unknown keys such as visibleOnly", () => { + expect(() => + listParameterDefinitionsInputSchema.parse({visibleOnly: true}), + ).toThrow(); + }); + }); + + describe("listParameterDefinitionsOutputSchema", () => { + it("accepts parameters-only output", () => { + expect( + listParameterDefinitionsOutputSchema.parse({ + parameters: [ + { + id: "width", + name: "Width", + type: "Int", + settable: true, + }, + ], + }), + ).toEqual({ + parameters: [ + { + id: "width", + name: "Width", + type: "Int", + settable: true, + }, + ], + }); + }); + + it("accepts optional errors array", () => { + expect( + listParameterDefinitionsOutputSchema.parse({ + parameters: [], + errors: [{name: "*", message: "Invalid input"}], + }), + ).toEqual({ + parameters: [], + errors: [{name: "*", message: "Invalid input"}], + }); + }); + }); + + describe("formatToolInputError", () => { + it("formats Error instances", () => { + expect(formatToolInputError(new Error("bad input"))).toEqual({ + errors: [{name: "*", message: "bad input"}], + }); + }); + + it("formats non-Error values", () => { + expect(formatToolInputError("bad input")).toEqual({ + errors: [{name: "*", message: "bad input"}], + }); + }); + }); + + describe("setParameterValuesInputSchema", () => { + it("accepts valid updates", () => { + expect( + setParameterValuesInputSchema.parse({ + updates: [ + {name: "Width", value: 10}, + { + name: "Color", + value: {red: 255, green: 0, blue: 0, alpha: 255}, + }, + ], + }), + ).toEqual({ + updates: [ + {name: "Width", value: 10}, + { + name: "Color", + value: {red: 255, green: 0, blue: 0, alpha: 255}, + }, + ], + }); + }); + + it("rejects missing updates array", () => { + expect(() => setParameterValuesInputSchema.parse({})).toThrow(); + }); + }); + + describe("createModelStateInputSchema", () => { + it("accepts optional fields", () => { + expect( + createModelStateInputSchema.parse({ + includeImage: true, + includeGltf: false, + data: {foo: "bar"}, + }), + ).toEqual({ + includeImage: true, + includeGltf: false, + data: {foo: "bar"}, + }); + }); + + it("rejects non-object input", () => { + expect(() => createModelStateInputSchema.parse("bad")).toThrow(); + }); + }); + + describe("importModelStateInputSchema", () => { + it("accepts modelStateId", () => { + expect( + importModelStateInputSchema.parse({ + modelStateId: "abc-123", + }), + ).toEqual({modelStateId: "abc-123"}); + }); + + it("rejects missing modelStateId", () => { + expect(() => importModelStateInputSchema.parse({})).toThrow(); + }); + }); + + describe("importModelStateSuccessOutputSchema", () => { + it("accepts success output with optional invalidParameters", () => { + expect( + importModelStateSuccessOutputSchema.parse({ + success: true, + appliedParameterIds: ["width"], + invalidParameters: [ + { + name: "unknown", + message: + 'Parameter "unknown" does not exist in the current model session.', + }, + ], + }), + ).toEqual({ + success: true, + appliedParameterIds: ["width"], + invalidParameters: [ + { + name: "unknown", + message: + 'Parameter "unknown" does not exist in the current model session.', + }, + ], + }); + }); + + it("accepts empty appliedParameterIds", () => { + expect( + importModelStateSuccessOutputSchema.parse({ + success: true, + appliedParameterIds: [], + }), + ).toEqual({ + success: true, + appliedParameterIds: [], + }); + }); + }); +}); \ No newline at end of file diff --git a/features/webmcp/__tests__/zodToJsonSchema.test.ts b/features/webmcp/__tests__/zodToJsonSchema.test.ts new file mode 100644 index 00000000..c185f8b0 --- /dev/null +++ b/features/webmcp/__tests__/zodToJsonSchema.test.ts @@ -0,0 +1,70 @@ +import {z} from "zod"; +import {zodToJsonSchema} from "../lib/zodToJsonSchema"; + +describe("zodToJsonSchema", () => { + it("converts object schema with optional fields, arrays, enums, unions, and records", () => { + const colorSchema = z.object({ + red: z.number(), + green: z.number(), + blue: z.number(), + alpha: z.number(), + }); + + const schema = z.object({ + name: z.string(), + count: z.number().optional(), + enabled: z.boolean(), + filter: z.enum(["all", "visible"]), + value: z.union([z.string(), z.number(), z.boolean(), colorSchema]), + tags: z.array(z.string()), + metadata: z.record(z.string(), z.any()), + }); + + const jsonSchema = zodToJsonSchema(schema); + + expect(jsonSchema).toEqual({ + type: "object", + properties: { + name: {type: "string"}, + count: {type: "number"}, + enabled: {type: "boolean"}, + filter: {type: "string", enum: ["all", "visible"]}, + value: { + anyOf: [ + {type: "string"}, + {type: "number"}, + {type: "boolean"}, + { + type: "object", + properties: { + red: {type: "number"}, + green: {type: "number"}, + blue: {type: "number"}, + alpha: {type: "number"}, + }, + required: ["red", "green", "blue", "alpha"], + additionalProperties: false, + }, + ], + }, + tags: { + type: "array", + items: {type: "string"}, + }, + metadata: { + type: "object", + additionalProperties: {}, + }, + }, + required: ["name", "enabled", "filter", "value", "tags", "metadata"], + additionalProperties: false, + }); + }); + + it("converts literal schemas", () => { + expect(zodToJsonSchema(z.literal(true))).toEqual({ + type: "boolean", + const: true, + }); + }); +}); diff --git a/features/webmcp/config/createModelState.ts b/features/webmcp/config/createModelState.ts new file mode 100644 index 00000000..89b4a6d9 --- /dev/null +++ b/features/webmcp/config/createModelState.ts @@ -0,0 +1,6 @@ +import {createModelStateDataSchema} from "@AppBuilderLib/features/model-state/config/createModelState.zod"; + +/** WebMCP input = hook data without `image` (agents don't set export screenshot refs). */ +export const createModelStateInputSchema = createModelStateDataSchema.omit({ + image: true, +}); diff --git a/features/webmcp/config/importModelState.ts b/features/webmcp/config/importModelState.ts new file mode 100644 index 00000000..632c16ad --- /dev/null +++ b/features/webmcp/config/importModelState.ts @@ -0,0 +1,13 @@ +import { + importModelStateDataSchema, + nameMessageSchema, +} from "@AppBuilderLib/features/model-state/config/importModelState.zod"; +import {z} from "zod"; + +export const importModelStateInputSchema = importModelStateDataSchema; + +export const importModelStateSuccessOutputSchema = z.object({ + success: z.literal(true), + appliedParameterIds: z.array(z.string()), + invalidParameters: z.array(nameMessageSchema).optional(), +}); diff --git a/features/webmcp/config/listParameterDefinitions.ts b/features/webmcp/config/listParameterDefinitions.ts new file mode 100644 index 00000000..e1d6bcee --- /dev/null +++ b/features/webmcp/config/listParameterDefinitions.ts @@ -0,0 +1,69 @@ +import {nameMessageSchema} from "@AppBuilderLib/features/model-state/config/importModelState.zod"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import {z} from "zod"; + +const colorValueSchema = z.object({ + red: z.number(), + green: z.number(), + blue: z.number(), + alpha: z.number(), +}); + +export const parameterValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + colorValueSchema, +]); + +const ListParameterDefinitionItemSchema = z.object({ + id: z.string(), + name: z.string(), + displayname: z.string().optional(), + type: z.string(), + group: z.string().optional(), + tooltip: z.string().optional(), + min: z.number().nullable().optional(), + max: z.number().nullable().optional(), + decimalplaces: z.number().nullable().optional(), + choices: z.array(z.string()).optional(), + currentValue: parameterValueSchema.optional(), + defaultValue: parameterValueSchema.optional(), + hidden: z.boolean().optional(), + settable: z.boolean(), +}); + +export const listParameterDefinitionsInputSchema = z.strictObject({ + filter: z + .enum(["all", "visible"]) + .optional() + .describe( + "all = every parameter; visible = parameters not hidden by the model (definition.hidden === false). Defaults to all.", + ), + sessionId: z + .string() + .optional() + .describe("Optional session namespace. Omit for the main model."), +}); + +export const listParameterDefinitionsOutputSchema = z.object({ + parameters: z.array(ListParameterDefinitionItemSchema), + errors: z.array(nameMessageSchema).optional(), +}); + +export type ListParameterDefinitionItem = z.infer< + typeof ListParameterDefinitionItemSchema +>; + +/** Supported types of parameters (for now). */ +// TODO SS-9745: Grasshopper-dev-controlled exposure for additional parameter types. +export const SUPPORTED_PARAMETER_TYPES: ResParameterType[] = [ + ResParameterType.BOOL, + ResParameterType.COLOR, + ResParameterType.EVEN, + ResParameterType.FLOAT, + ResParameterType.INT, + ResParameterType.ODD, + ResParameterType.STRING, + ResParameterType.STRINGLIST, +]; diff --git a/features/webmcp/config/setParameterValues.ts b/features/webmcp/config/setParameterValues.ts new file mode 100644 index 00000000..b797bb98 --- /dev/null +++ b/features/webmcp/config/setParameterValues.ts @@ -0,0 +1,33 @@ +import {nameMessageSchema} from "@AppBuilderLib/features/model-state/config/importModelState.zod"; +import {z} from "zod"; +import {parameterValueSchema} from "./listParameterDefinitions"; + +const setParameterUpdateSchema = z.strictObject({ + name: z + .string() + .describe( + "Parameter id, internal name, or display name from list_parameter_definitions.", + ), + sessionId: z + .string() + .optional() + .describe("Optional session namespace. Omit for the main model."), + value: parameterValueSchema.describe( + "New value. StringList: 0-based integer index (e.g. 1 for second choice), not the label text and not {index:N}.", + ), +}); + +export const setParameterValuesInputSchema = z.strictObject({ + updates: z + .array(setParameterUpdateSchema) + .describe( + "Required array of changes. Use this field name exactly — not parameters or ids.", + ), +}); + +export type ParameterUpdateInput = z.infer; +export type SetParameterValuesError = z.infer; +export type SetParameterValuesOutput = { + applied: string[]; + errors: SetParameterValuesError[]; +}; diff --git a/features/webmcp/config/tools.ts b/features/webmcp/config/tools.ts new file mode 100644 index 00000000..c7f1d62b --- /dev/null +++ b/features/webmcp/config/tools.ts @@ -0,0 +1,42 @@ +export const LIST_PARAMETER_DEFINITIONS_TOOL_NAME = + "list_parameter_definitions"; + +export const SET_PARAMETER_VALUES_TOOL_NAME = "set_parameter_values"; + +export const CREATE_MODEL_STATE_TOOL_NAME = "create_model_state"; + +export const IMPORT_MODEL_STATE_TOOL_NAME = "import_model_state"; + +export const LIST_PARAMETER_DEFINITIONS_TOOL_DESCRIPTION = + "Read configurator parameters before changing anything. " + + "Input: { filter?: 'all' | 'visible' } — field name is filter, not visibleOnly. " + + "Returns { parameters: [...], errors?: [{ name, message }] } with id, name, type, settable, choices/min/max, currentValue. " + + "settable=false means read-only via set_parameter_values (unsupported type). " + + "On invalid input, returns { parameters: [], errors: [{ name: '*', message }] }. " + + "Trust type over display name (e.g. name Color may still be StringList). " + + "Call with filter=all first when you do not know parameter names or value rules."; + +export const SET_PARAMETER_VALUES_TOOL_DESCRIPTION = + "Change configurator parameters. " + + "Input shape: { updates: [{ name, value }] } — use updates and name, not parameters/id. " + + "Value rules: Bool=boolean; Int/Float/Even/Odd=number in range; String=text; " + + "StringList=0-based integer index only (1 = second choice), never the label and never {index:N}; " + + "Color={red,green,blue,alpha} 0-255. " + + "Returns { applied: string[], errors: [{ name, message }] }. Valid updates still apply when others fail."; + +export const CREATE_MODEL_STATE_TOOL_DESCRIPTION = + "Save current configurator state for sharing or later restore via import_model_state. " + + "Input: { includeImage?: boolean, includeGltf?: boolean, parameterNamesToInclude?: string[], parameterNamesToExclude?: string[], data?: object }. " + + "Use includeImage:false when no preview screenshot is needed; includeGltf:true only when a GLTF export is required. " + + "Success: { success: true, modelStateId: string, modelViewUrl: string, modelStateImageUrl?: string, modelStateGltfUrl?: string, modelStateUsdzUrl?: string }. " + + "modelStateId is required for import_model_state. Image/GLTF/USDZ URL fields appear only when the matching include* flag was true. " + + "Failure: { success: false, error: string }."; + +export const IMPORT_MODEL_STATE_TOOL_DESCRIPTION = + "Restore a saved configuration. " + + "Input: { modelStateId } from create_model_state (or URL containing modelStateId). " + + "Waits for session update before returning. " + + "Success: { success: true, appliedParameterIds: string[], invalidParameters?: [{ name, message }] } — appliedParameterIds lists parameter ids whose values changed during import (empty array when none changed). " + + "invalidParameters appears on partial success when some saved parameters could not be applied. " + + "Failure: { success: false, message: string, invalidParameters?: [{ name, message }] } — per-parameter reasons when saved state does not match current model. " + + "Use list_parameter_definitions after import to verify currentValue."; diff --git a/features/webmcp/evals/__fixtures__/parameters.ts b/features/webmcp/evals/__fixtures__/parameters.ts new file mode 100644 index 00000000..2cb5fb31 --- /dev/null +++ b/features/webmcp/evals/__fixtures__/parameters.ts @@ -0,0 +1,138 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {composeSdColor} from "@AppBuilderLib/shared/lib/colors"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; + +export const EVAL_NAMESPACE = "eval-session"; + +function createMockParameter( + overrides: Partial> & { + definition: IShapeDiverParameter["definition"]; + }, +): IShapeDiverParameter { + return { + state: { + uiValue: overrides.state?.uiValue, + execValue: overrides.state?.execValue, + dirty: false, + disableOtherParameters: false, + stringExecValue: () => "", + }, + actions: { + setUiValue: () => true, + setUiAndExecValue: () => true, + execute: async () => "", + isValid: overrides.actions?.isValid ?? (() => true), + isUiValueDifferent: () => false, + resetToDefaultValue: () => undefined, + resetToExecValue: () => undefined, + }, + acceptRejectMode: false, + ...overrides, + } as IShapeDiverParameter; +} + +export const intParameter = createMockParameter({ + definition: { + id: "width-int", + name: "Width", + displayname: "Width", + type: ResParameterType.INT, + min: 0, + max: 10, + defval: 5, + group: {name: "Dimensions"}, + tooltip: "Model width", + } as unknown as IShapeDiverParameter["definition"], + state: {uiValue: 5} as IShapeDiverParameter["state"], + actions: { + isValid: (value) => + typeof value === "number" && value >= 0 && value <= 10, + } as IShapeDiverParameter["actions"], +}); + +export const stringListParameter = createMockParameter({ + definition: { + id: "material-list", + name: "Material", + type: ResParameterType.STRINGLIST, + choices: ["Wood", "Metal", "Glass"], + defval: 0, + } as unknown as IShapeDiverParameter["definition"], + state: {uiValue: 1} as IShapeDiverParameter["state"], + actions: { + isValid: (value) => { + const index = typeof value === "number" ? value : Number(value); + + return ( + Number.isInteger(index) && index >= 0 && index < 3 + ); + }, + } as IShapeDiverParameter["actions"], +}); + +export const colorParameter = createMockParameter({ + definition: { + id: "paint-color", + name: "Paint", + type: ResParameterType.COLOR, + defval: composeSdColor({red: 0, green: 0, blue: 0, alpha: 255}), + } as unknown as IShapeDiverParameter["definition"], + state: { + uiValue: composeSdColor({red: 255, green: 0, blue: 0, alpha: 255}), + } as IShapeDiverParameter["state"], +}); + +export const floatParameter = createMockParameter({ + definition: { + id: "height-float", + name: "Height", + type: ResParameterType.FLOAT, + min: 0, + max: 100, + decimalplaces: 2, + defval: 10, + } as unknown as IShapeDiverParameter["definition"], + state: {uiValue: 42.5} as IShapeDiverParameter["state"], +}); + +export const boolParameter = createMockParameter({ + definition: { + id: "enabled-bool", + name: "Enabled", + type: ResParameterType.BOOL, + defval: false, + } as unknown as IShapeDiverParameter["definition"], + state: {uiValue: true} as IShapeDiverParameter["state"], +}); + +/** Name says Color but type is StringList — common weak-model trap (SS-8076). */ +export const colorNamedStringListParameter = createMockParameter({ + definition: { + id: "color-list", + name: "Color", + type: ResParameterType.STRINGLIST, + choices: ["Red", "Blue", "Green"], + defval: 0, + } as unknown as IShapeDiverParameter["definition"], + state: {uiValue: 7} as IShapeDiverParameter["state"], +}); + +export const fileParameter = createMockParameter({ + definition: { + id: "upload-file", + name: "Upload", + type: ResParameterType.FILE, + hidden: true, + } as unknown as IShapeDiverParameter["definition"], + state: {uiValue: ""} as IShapeDiverParameter["state"], +}); + +export const allParameters: IShapeDiverParameter[] = [ + intParameter, + stringListParameter, + colorParameter, + floatParameter, + boolParameter, + colorNamedStringListParameter, + fileParameter, +]; diff --git a/features/webmcp/evals/__tests__/webmcpEvals.test.ts b/features/webmcp/evals/__tests__/webmcpEvals.test.ts new file mode 100644 index 00000000..7562504b --- /dev/null +++ b/features/webmcp/evals/__tests__/webmcpEvals.test.ts @@ -0,0 +1,14 @@ +import { + loadWebmcpEvalScenarios, + runWebmcpEvalScenario, +} from "../runWebmcpEvalScenarios"; + +describe("webmcp eval scenarios", () => { + const scenarios = loadWebmcpEvalScenarios(); + + it.each(scenarios)("$id: $description", async (scenario) => { + const failure = await runWebmcpEvalScenario(scenario); + + expect(failure).toBeNull(); + }); +}); diff --git a/features/webmcp/evals/agent_scenarios.md b/features/webmcp/evals/agent_scenarios.md new file mode 100644 index 00000000..2ad29b22 --- /dev/null +++ b/features/webmcp/evals/agent_scenarios.md @@ -0,0 +1,174 @@ +# WebMCP live agent QA scenarios — SS-8076 + +Weak-model stress tests. Run against `http://localhost:3000/?g=SS-8076.json` via Chrome DevTools MCP. + +## Harness + +1. Navigate to the URL, wait for model + parameters loaded. +2. In the page console, `mc` is the WebMCP client (`document.modelContext` / `navigator.modelContext`). +3. Resolve tools once: `const tools = await mc.getTools(); const tool = (name) => tools.find((t) => t.name === name);` +4. Call tools: `const r = await mc.executeTool(tool("list_parameter_definitions"), JSON.stringify(input));` + - First arg is the **RegisteredTool** from `getTools()` (not a string name). + - Second arg MUST be a JSON string. +5. `await mc.getTools()` lists registered tools (4 expected, snake_case names). + +**Schema reject:** invalid input returns `{ errors: [{ name: "*", message }] }` or `{ success: false, message }` — does **not** throw. + +Record per scenario: tool called, input sent, raw result, pass/fail vs expectation. + +## Model note (SS-8076.json) + +This JSON exposes **StringList UI widgets only** (ButtonChipGroup, DropDown, Checklist, **Color** trap, …). There is **no** Width / Material / Paint / Enabled — scenarios 4–6, 8–12, 15–16, 25 are **N/A** on this model. Use headless `evals.json` fixtures or another slug for INT/COLOR/BOOL coverage. + +## Conventions + +- Discover params first with `list_parameter_definitions` `{filter:"all"}` to learn real names/ids/types. +- On SS-8076, **Color** = StringList trap (label vs index). Width/Material/Paint apply only on other models. +- Indices are 0-based integers. + +## Scenarios + +### 1. discover_all +Task: "List every parameter of this model." +Tool: `list_parameter_definitions`, input `{"filter":"all"}` +Expect: `parameters` array, each has `settable` boolean. No `errors`. + +### 2. discover_visible +Task: "List only the visible (non-hidden) parameters." +Tool: `list_parameter_definitions`, input `{"filter":"visible"}` +Expect: `parameters` array; count <= all; no param with `hidden:true`. + +### 3. reject_visibleOnly_alias +Task: "List visibleOnly true." (weak-model alias) +Tool: `list_parameter_definitions`, input `{"visibleOnly":true}` +Expect: schema reject (error / `unrecognized_keys`), NOT a silent all-params list. + +### 4. set_valid_int +Task: "Set Width to a valid value within range." +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":7}]}` +Expect: `applied` contains the width id; `errors:[]`. + +### 5. set_out_of_range +Task: "Set Width to 999." +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":999}]}` +Expect: `applied:[]`, one error for Width (not valid / out of range). + +### 6. set_wrong_type +Task: "Set Width to the string 'wide'." +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":"wide"}]}` +Expect: `applied:[]`, error (not valid for parameter). + +### 7. set_unknown_param +Task: "Set a parameter named Foo to 5." +Tool: `set_parameter_values`, input `{"updates":[{"name":"Foo","value":5}]}` +Expect: `applied:[]`, error message includes "does not exist". + +### 8. reject_parameters_alias +Task: "Set width using the parameters field with id." (weak-model alias) +Tool: `set_parameter_values`, input `{"parameters":[{"id":"","value":7}]}` +Expect: schema reject (`unrecognized_keys`), NOT applied. + +### 9. reject_id_in_update +Task: "Set width using id inside the update item." (weak-model alias) +Tool: `set_parameter_values`, input `{"updates":[{"id":"","value":7}]}` +Expect: schema reject. + +### 10. stringlist_label_trap +Task: "Set Material to Metal." (label, not index) +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":"Metal"}]}` +Expect: `applied:[]`, error (not valid for parameter). + +### 11. stringlist_index_correct +Task: "Set Material to choice index 1." +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":1}]}` +Expect: `applied` contains material id; `errors:[]`. + +### 12. stringlist_out_of_range +Task: "Set Material to index 99." +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":99}]}` +Expect: `applied:[]`, error. + +### 13. color_named_stringlist_trap +Task: "Set Color to Red." (param named Color but type is StringList) +Tool: `set_parameter_values`, input `{"updates":[{"name":"Color","value":"Red"}]}` +Expect: `applied:[]`, error (not valid for parameter). Weak models must NOT assume "Color" = color type. + +### 14. color_named_stringlist_index +Task: "Set Color to index 0." +Tool: `set_parameter_values`, input `{"updates":[{"name":"Color","value":0}]}` +Expect: `applied` contains the color-list id; `errors:[]`. + +### 15. color_object_valid +Task: "Set Paint to red." (send decomposed color object) +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":{"red":255,"green":0,"blue":0,"alpha":255}}]}` +Expect: `applied` contains paint id; `errors:[]`. + +### 16. color_object_on_noncolor +Task: "Set Width to a color object." +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":{"red":1,"green":2,"blue":3,"alpha":4}}]}` +Expect: `applied:[]`, error "Color object value is only valid for Color parameters." + +### 17. partial_batch +Task: "Set Width to 7 and Foo to 1 in one call." +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":7},{"name":"Foo","value":1}]}` +Expect: `applied` contains width id; one error for Foo ("does not exist"). + +### 18. duplicate_update +Task: "Set Width to 7 and also set the same parameter by id to 8 in one call." +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":7},{"name":"","value":8}]}` +Expect: `applied` contains width id once; one error ("twice"). + +### 19. create_model_state +Task: "Create a model state of the current configuration." +Tool: `create_model_state`, input `{}` +Expect: `success:true`, `modelStateId` returned. + +### 20. import_model_state_roundtrip +Task: "Import the model state just created." +Tool: `import_model_state`, input `{"modelStateId":""}` +Expect: `success:true`, `appliedParameterIds` array (possibly empty if no diff). + +### 21. import_invalid_id +Task: "Import a non-existent model state id." +Tool: `import_model_state`, input `{"modelStateId":"does-not-exist-xyz"}` +Expect: `success:false` with a message (fetch error). + +### 22. import_wrong_type +Task: "Import model state with a numeric id." +Tool: `import_model_state`, input `{"modelStateId":12345}` +Expect: schema reject. + +### 23. unknown_tool +Task: "Call a tool named get_params." +Expect: tool not registered / error. Only 4 tools exist. + +### 24. full_workflow +Task: "List visible params, set the first settable one to a valid value, create a state, import it back." +Expect: each step succeeds; final import `success:true`. + +### 25. bool_toggle +Task: "Set the Enabled boolean to true." +Tool: `set_parameter_values`, input `{"updates":[{"name":"","value":true}]}` +Expect: `applied` contains enabled id; `errors:[]`. + +## Report format + +For each scenario return: +- id +- status: pass / fail / partial +- tool + input actually sent +- result snippet (applied / errors / success) +- failure cause (if any): schema reject missed, wrong field, label-vs-index, type confusion, etc. + +End with: aggregate pass rate, list of weak-model failure patterns observed, suggestions. + +## Weak-model patterns (Haiku 4.5, observed) + +| Pattern | Example | Mitigation | +|---------|---------|------------| +| camelCase tool names | `listParameterDefinitions` | `getTools()` — only snake_case registered | +| Wrong input keys | `visibleOnly`, `parameters`, `id`, `visible` | `strictObject` + `unrecognized_keys` in errors | +| `updates` as object | `{"Width": 7}` | Schema requires `updates: [{ name, value }]` | +| StringList label | `Color: "Red"` | Error hints with choices + index range | +| Color shape | `{r,g,b}` | Schema/docs: `{red,green,blue,alpha}` | +| Import key | `id` not `modelStateId` | `strictObject` on import input | diff --git a/features/webmcp/evals/evals.json b/features/webmcp/evals/evals.json new file mode 100644 index 00000000..35879c79 --- /dev/null +++ b/features/webmcp/evals/evals.json @@ -0,0 +1,268 @@ +[ + { + "id": "list_all", + "tool": "list_parameter_definitions", + "description": "List all supported parameters", + "input": {"filter": "all"}, + "expect": {"parametersCount": 7} + }, + { + "id": "list_visible", + "tool": "list_parameter_definitions", + "description": "List only non-hidden parameters", + "input": {"filter": "visible"}, + "expect": { + "parametersCount": 6, + "parameterIds": [ + "width-int", + "material-list", + "paint-color", + "height-float", + "enabled-bool", + "color-list" + ] + } + }, + { + "id": "list_reject_visible_only", + "tool": "list_parameter_definitions", + "description": "Reject weak-model alias visibleOnly (use filter instead)", + "input": {"visibleOnly": true}, + "expect": {"inputSchemaReject": true} + }, + { + "id": "set_valid_int", + "tool": "set_parameter_values", + "description": "Set INT within valid range", + "input": { + "updates": [{"name": "Width", "value": 7}] + }, + "expect": {"applied": ["width-int"], "errors": []} + }, + { + "id": "set_out_of_range", + "tool": "set_parameter_values", + "description": "Reject INT beyond max", + "input": { + "updates": [{"name": "Width", "value": 99}] + }, + "expect": {"applied": [], "errorsNonEmpty": true, "appliedExcludes": ["width-int"], "errorMessageIncludes": "range"} + }, + { + "id": "set_unknown_id", + "tool": "set_parameter_values", + "description": "Reject unknown parameter name", + "input": { + "updates": [{"name": "DoesNotExist", "value": 1}] + }, + "expect": { + "applied": [], + "errorsNonEmpty": true, + "errorMessageIncludes": "does not exist" + } + }, + { + "id": "set_wrong_type", + "tool": "set_parameter_values", + "description": "Reject string value for INT parameter", + "input": { + "updates": [{"name": "Width", "value": "not-a-number"}] + }, + "expect": { + "applied": [], + "errorsNonEmpty": true, + "errorMessageIncludes": "not valid for parameter" + } + }, + { + "id": "set_duplicate", + "tool": "set_parameter_values", + "description": "Reject duplicate updates for same parameter", + "input": { + "updates": [ + {"name": "Width", "value": 3}, + {"name": "width-int", "value": 4} + ] + }, + "expect": { + "applied": ["width-int"], + "errorsNonEmpty": true, + "errorMessageIncludes": "twice" + } + }, + { + "id": "set_reject_parameters_key", + "tool": "set_parameter_values", + "description": "Reject MCP-style parameters array (use updates)", + "input": { + "parameters": [{"id": "width-int", "value": 7}] + }, + "expect": {"inputSchemaReject": true} + }, + { + "id": "set_reject_id_in_update", + "tool": "set_parameter_values", + "description": "Reject id field inside update item (use name)", + "input": { + "updates": [{"id": "width-int", "value": 7}] + }, + "expect": {"inputSchemaReject": true} + }, + { + "id": "set_partial_apply", + "tool": "set_parameter_values", + "description": "Apply valid updates even when others fail", + "input": { + "updates": [ + {"name": "Width", "value": 7}, + {"name": "DoesNotExist", "value": 1} + ] + }, + "expect": { + "applied": ["width-int"], + "errorsNonEmpty": true, + "errorMessageIncludes": "does not exist" + } + }, + { + "id": "set_color_valid", + "tool": "set_parameter_values", + "description": "Set COLOR with decomposed channels", + "input": { + "updates": [ + { + "name": "Paint", + "value": {"red": 10, "green": 20, "blue": 30, "alpha": 255} + } + ] + }, + "expect": {"applied": ["paint-color"], "errors": []} + }, + { + "id": "set_stringlist_valid", + "tool": "set_parameter_values", + "description": "Set StringList with numeric index", + "input": { + "updates": [{"name": "Material", "value": 1}] + }, + "expect": {"applied": ["material-list"], "errors": []} + }, + { + "id": "set_stringlist_label", + "tool": "set_parameter_values", + "description": "Reject StringList choice label (weak models send Metal not 1)", + "input": { + "updates": [{"name": "Material", "value": "Metal"}] + }, + "expect": { + "applied": [], + "errorsNonEmpty": true, + "errorMessageIncludes": "0-based integer index" + } + }, + { + "id": "set_stringlist_out_of_range", + "tool": "set_parameter_values", + "description": "Reject StringList index beyond choices", + "input": { + "updates": [{"name": "Material", "value": 99}] + }, + "expect": { + "applied": [], + "errorsNonEmpty": true, + "errorMessageIncludes": "not valid" + } + }, + { + "id": "set_stringlist_object_index", + "tool": "set_parameter_values", + "description": "Reject StringList {index:N} object (use plain integer)", + "input": { + "updates": [{"name": "Material", "value": {"index": 1}}] + }, + "expect": {"inputSchemaReject": true} + }, + { + "id": "set_batch_all_invalid", + "tool": "set_parameter_values", + "description": "Batch with label + out-of-range — nothing applied", + "input": { + "updates": [ + {"name": "Material", "value": "Metal"}, + {"name": "Width", "value": 99} + ] + }, + "expect": { + "applied": [], + "errorCount": 2, + "errorMessageIncludesAll": ["0-based integer index", "range"] + } + }, + { + "id": "set_color_named_stringlist_label", + "tool": "set_parameter_values", + "description": "Color param is StringList — reject label Red", + "input": { + "updates": [{"name": "Color", "value": "Red"}] + }, + "expect": { + "applied": [], + "errorsNonEmpty": true, + "errorMessageIncludes": "0-based integer index" + } + }, + { + "id": "set_color_named_stringlist_index", + "tool": "set_parameter_values", + "description": "Color param is StringList — accept index 0 for Red", + "input": { + "updates": [{"name": "Color", "value": 0}] + }, + "expect": {"applied": ["color-list"], "errors": []} + }, + { + "id": "create_model_state", + "tool": "create_model_state", + "description": "Validate create_model_state input schema (execution mocked)", + "input": { + "parameterNamesToInclude": ["Width"], + "includeImage": true + }, + "expect": {"success": true} + }, + { + "id": "create_include_image_false", + "tool": "create_model_state", + "description": "Accept includeImage:false (common demo save path)", + "input": {"includeImage": false}, + "expect": {"success": true} + }, + { + "id": "create_reject_unknown_key", + "tool": "create_model_state", + "description": "Reject unknown create_model_state keys", + "input": {"includeImage": false, "saveSnapshot": true}, + "expect": {"success": false} + }, + { + "id": "import_valid", + "tool": "import_model_state", + "description": "Validate import_model_state input schema (execution mocked)", + "input": {"modelStateId": "ms-abc-123"}, + "expect": {"success": true} + }, + { + "id": "import_invalid", + "tool": "import_model_state", + "description": "Reject invalid import_model_state input type", + "input": {"modelStateId": 12345}, + "expect": {"success": false} + }, + { + "id": "import_reject_unknown_key", + "tool": "import_model_state", + "description": "Reject unknown import_model_state keys", + "input": {"modelStateId": "ms-abc-123", "applied": true}, + "expect": {"success": false} + } +] diff --git a/features/webmcp/evals/runWebmcpEvalScenarios.ts b/features/webmcp/evals/runWebmcpEvalScenarios.ts new file mode 100644 index 00000000..1e019897 --- /dev/null +++ b/features/webmcp/evals/runWebmcpEvalScenarios.ts @@ -0,0 +1,243 @@ +import {z} from "zod"; +import {createModelStateInputSchema} from "../config/createModelState"; +import {importModelStateInputSchema} from "../config/importModelState"; +import { + listParameterDefinitionsInputSchema, + listParameterDefinitionsOutputSchema, +} from "../config/listParameterDefinitions"; +import {setParameterValuesInputSchema} from "../config/setParameterValues"; +import {formatToolInputError} from "../lib/formatToolInputError"; +import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; +import {resolveAndUpdate} from "../lib/resolveSetParameterUpdates"; +import {allParameters, EVAL_NAMESPACE} from "./__fixtures__/parameters"; +import evalScenariosJson from "./evals.json"; + +export interface EvalExpect { + applied?: string[]; + errors?: Array<{name: string; message: string}>; + success?: boolean; + parametersCount?: number; + parameterIds?: string[]; + errorsNonEmpty?: boolean; + appliedExcludes?: string[]; + errorMessageIncludes?: string; + errorMessageIncludesAll?: string[]; + errorCount?: number; + inputSchemaReject?: boolean; +} + +export interface EvalScenario { + id: string; + tool: string; + description: string; + input: Record; + expect: EvalExpect; +} + +export function loadWebmcpEvalScenarios(): EvalScenario[] { + return evalScenariosJson as EvalScenario[]; +} + +function runListScenario(input: Record) { + try { + const parsed = listParameterDefinitionsInputSchema.parse(input); + const filter = parsed.filter ?? "all"; + let parameters = allParameters; + + if (filter === "visible") { + parameters = parameters.filter((p) => !p.definition.hidden); + } + + return { + parameters: parameters.map((param) => + mapParameterDefinition(param), + ), + }; + } catch (e) { + return { + parameters: [], + ...formatToolInputError(e), + }; + } +} + +function assertInputSchemaReject( + schema: z.ZodType, + input: Record, +): string | null { + try { + schema.parse(input); + + return "expected input schema validation to fail"; + } catch { + return null; + } +} + +function assertSetErrorExpectations( + result: {applied: string[]; errors: Array<{name: string; message: string}>}, + expect: EvalExpect, +): string | null { + if (expect.applied !== undefined) { + const appliedSorted = [...result.applied].sort(); + const expectedSorted = [...expect.applied].sort(); + + if (JSON.stringify(appliedSorted) !== JSON.stringify(expectedSorted)) { + return `expected applied ${JSON.stringify(expect.applied)}, got ${JSON.stringify(result.applied)}`; + } + } + + if (expect.appliedExcludes !== undefined) { + for (const id of expect.appliedExcludes) { + if (result.applied.includes(id)) { + return `expected applied to exclude "${id}"`; + } + } + } + + if (expect.errors !== undefined) { + if (JSON.stringify(result.errors) !== JSON.stringify(expect.errors)) { + return `expected errors ${JSON.stringify(expect.errors)}, got ${JSON.stringify(result.errors)}`; + } + } + + if (expect.errorsNonEmpty && result.errors.length === 0) { + return "expected non-empty errors array"; + } + + if ( + expect.errorCount !== undefined && + result.errors.length !== expect.errorCount + ) { + return `expected ${expect.errorCount} error(s), got ${result.errors.length}`; + } + + if (expect.errorMessageIncludes !== undefined) { + const found = result.errors.some((error) => + error.message.includes(expect.errorMessageIncludes!), + ); + + if (!found) { + return `expected an error message containing "${expect.errorMessageIncludes}"`; + } + } + + if (expect.errorMessageIncludesAll !== undefined) { + for (const fragment of expect.errorMessageIncludesAll) { + const found = result.errors.some((error) => + error.message.includes(fragment), + ); + + if (!found) { + return `expected an error message containing "${fragment}"`; + } + } + } + + return null; +} + +function assertListScenario(scenario: EvalScenario): string | null { + if (scenario.expect.inputSchemaReject) { + const result = runListScenario(scenario.input); + const parsed = listParameterDefinitionsOutputSchema.safeParse(result); + + if (!parsed.success) { + return "list output did not match schema after input rejection"; + } + + if (!parsed.data.errors?.length) { + return "expected non-empty errors array for invalid input"; + } + + return null; + } + + const result = runListScenario(scenario.input); + const parameters = result.parameters; + const {expect} = scenario; + + if ( + expect.parametersCount !== undefined && + parameters.length !== expect.parametersCount + ) { + return `expected ${expect.parametersCount} parameters, got ${parameters.length}`; + } + + if (expect.parameterIds !== undefined) { + const ids = parameters.map((p) => p.id).sort(); + const expected = [...expect.parameterIds].sort(); + + if (JSON.stringify(ids) !== JSON.stringify(expected)) { + return `expected parameter ids ${JSON.stringify(expected)}, got ${JSON.stringify(ids)}`; + } + } + + return null; +} + +async function assertSetScenario( + scenario: EvalScenario, +): Promise { + if (scenario.expect.inputSchemaReject) { + return assertInputSchemaReject( + setParameterValuesInputSchema, + scenario.input, + ); + } + + const parsed = setParameterValuesInputSchema.parse(scenario.input); + + const result = await resolveAndUpdate( + EVAL_NAMESPACE, + (ns) => (ns === EVAL_NAMESPACE ? allParameters : []), + parsed.updates, + async () => undefined, + ); + + return assertSetErrorExpectations(result, scenario.expect); +} + +// TODO SS-9745: full create/import evals require a browser WebMCP runtime. +function assertSchemaScenario(scenario: EvalScenario): string | null { + const {tool, input, expect} = scenario; + const schema = + tool === "create_model_state" + ? createModelStateInputSchema + : tool === "import_model_state" + ? importModelStateInputSchema + : undefined; + + if (!schema) { + return `unknown schema tool "${tool}"`; + } + + if (expect.inputSchemaReject || expect.success === false) { + return assertInputSchemaReject(schema, input); + } + + try { + schema.parse(input); + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + + return null; +} + +/** Returns failure reason, or null when the scenario passes. */ +export async function runWebmcpEvalScenario( + scenario: EvalScenario, +): Promise { + switch (scenario.tool) { + case "list_parameter_definitions": + return assertListScenario(scenario); + case "set_parameter_values": + return assertSetScenario(scenario); + case "create_model_state": + case "import_model_state": + return assertSchemaScenario(scenario); + default: + return `unknown tool "${scenario.tool}"`; + } +} diff --git a/features/webmcp/evals/runWebmcpEvals.ts b/features/webmcp/evals/runWebmcpEvals.ts new file mode 100644 index 00000000..bfa827a2 --- /dev/null +++ b/features/webmcp/evals/runWebmcpEvals.ts @@ -0,0 +1,29 @@ +import { + loadWebmcpEvalScenarios, + runWebmcpEvalScenario, +} from "./runWebmcpEvalScenarios"; + +async function main() { + const scenarios = loadWebmcpEvalScenarios(); + let failed = 0; + + for (const scenario of scenarios) { + const reason = await runWebmcpEvalScenario(scenario); + + if (reason) { + console.log(`[FAIL] ${scenario.id}: ${reason}`); + failed += 1; + } else { + console.log(`[PASS] ${scenario.id}`); + } + } + + if (failed > 0) { + console.log(`\n${failed} scenario(s) failed.`); + process.exit(1); + } + + console.log(`\nAll ${scenarios.length} scenario(s) passed.`); +} + +void main(); diff --git a/features/webmcp/lib/computeAppliedParameterIds.ts b/features/webmcp/lib/computeAppliedParameterIds.ts new file mode 100644 index 00000000..f26344e8 --- /dev/null +++ b/features/webmcp/lib/computeAppliedParameterIds.ts @@ -0,0 +1,16 @@ +export interface ParameterValueSnapshot { + definition: {id: string}; + state: {uiValue: unknown}; +} + +/** + * Returns parameter ids whose uiValue changed between before and after snapshots. + */ +export function computeAppliedParameterIds( + beforeValues: Map, + afterParams: ParameterValueSnapshot[], +): string[] { + return afterParams + .filter((p) => beforeValues.get(p.definition.id) !== p.state.uiValue) + .map((p) => p.definition.id); +} diff --git a/features/webmcp/lib/findParameterByName.ts b/features/webmcp/lib/findParameterByName.ts new file mode 100644 index 00000000..c6547991 --- /dev/null +++ b/features/webmcp/lib/findParameterByName.ts @@ -0,0 +1,13 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; + +export function findParameterByName( + parameters: IShapeDiverParameter[], + name: string, +): IShapeDiverParameter | undefined { + return parameters.find( + (p) => + p.definition.id === name || + p.definition.name === name || + p.definition.displayname === name, + ); +} diff --git a/features/webmcp/lib/formatToolInputError.ts b/features/webmcp/lib/formatToolInputError.ts new file mode 100644 index 00000000..6296565f --- /dev/null +++ b/features/webmcp/lib/formatToolInputError.ts @@ -0,0 +1,12 @@ +export function formatToolInputError(e: unknown): { + errors: Array<{name: string; message: string}>; +} { + return { + errors: [ + { + name: "*", + message: e instanceof Error ? e.message : String(e), + }, + ], + }; +} diff --git a/features/webmcp/lib/parameterDefinitionMapper.ts b/features/webmcp/lib/parameterDefinitionMapper.ts new file mode 100644 index 00000000..b459ecdc --- /dev/null +++ b/features/webmcp/lib/parameterDefinitionMapper.ts @@ -0,0 +1,65 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {decomposeSdColor} from "@AppBuilderLib/shared/lib/colors"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import { + SUPPORTED_PARAMETER_TYPES, + type ListParameterDefinitionItem, +} from "../config/listParameterDefinitions"; +import {parseStringListIndex} from "./stringListValue"; + +export function mapParameterDefinition( + param: IShapeDiverParameter, +): ListParameterDefinitionItem { + const def = param.definition; + const currentValue = param.state.uiValue; + const name = def.displayname || def.name; + + const item: ListParameterDefinitionItem = { + id: def.id, + name, + type: def.type, + settable: SUPPORTED_PARAMETER_TYPES.includes(def.type), + }; + + if (def.hidden !== undefined) { + item.hidden = def.hidden; + } + + if (def.group?.name) { + item.group = def.group.name; + } + + if (def.tooltip) { + item.tooltip = def.tooltip; + } + + if (def.type === ResParameterType.STRINGLIST) { + item.choices = def.choices; + item.currentValue = parseStringListIndex(currentValue); + item.defaultValue = parseStringListIndex(def.defval); + } else if (def.type === ResParameterType.COLOR) { + item.currentValue = decomposeSdColor(currentValue as string); + item.defaultValue = decomposeSdColor(def.defval as string); + } else if ( + def.type === ResParameterType.EVEN || + def.type === ResParameterType.ODD || + def.type === ResParameterType.INT || + def.type === ResParameterType.FLOAT || + def.type === ResParameterType.STRING + ) { + if (def.type !== ResParameterType.STRING) { + item.min = def.min ?? null; + } + item.max = def.max ?? null; + if (def.type === ResParameterType.FLOAT) { + item.decimalplaces = def.decimalplaces ?? null; + } + item.currentValue = currentValue as string | number | boolean; + item.defaultValue = def.defval as string | number | boolean; + } else if (def.type === ResParameterType.BOOL) { + item.currentValue = currentValue as boolean; + item.defaultValue = def.defval; + } + + return item; +} diff --git a/features/webmcp/lib/resolveSetParameterUpdates.ts b/features/webmcp/lib/resolveSetParameterUpdates.ts new file mode 100644 index 00000000..7d575c9a --- /dev/null +++ b/features/webmcp/lib/resolveSetParameterUpdates.ts @@ -0,0 +1,71 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import type {IShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/config/shapediverStoreParameters"; +import {SUPPORTED_PARAMETER_TYPES} from "../config/listParameterDefinitions"; +import type { + ParameterUpdateInput, + SetParameterValuesError, + SetParameterValuesOutput, +} from "../config/setParameterValues"; +import {findParameterByName} from "./findParameterByName"; +import {prepareParameterStoreValue} from "./setParameterValueValidators/prepareParameterStoreValue"; + +export async function resolveAndUpdate( + defaultNamespace: string, + getParameters: (namespace: string) => IShapeDiverParameter[], + updates: ParameterUpdateInput[], + batchUpdate: IShapeDiverStoreParameters["batchParameterValueUpdate"], +): Promise { + const errors: SetParameterValuesError[] = []; + const valuesByNamespace: Record> = {}; + const processedIds = new Set(); + + for (const update of updates) { + const targetNamespace = update.sessionId ?? defaultNamespace; + const parameter = findParameterByName( + getParameters(targetNamespace), + update.name, + ); + + if (!parameter) { + errors.push({ + name: update.name, + message: `Parameter with id/name/displayname "${update.name}" does not exist.`, + }); + continue; + } + + const paramId = parameter.definition.id; + if (processedIds.has(paramId)) { + errors.push({ + name: update.name, + message: `Refusing to update parameter "${update.name}" twice.`, + }); + continue; + } + processedIds.add(paramId); + + if (!SUPPORTED_PARAMETER_TYPES.includes(parameter.definition.type)) { + errors.push({ + name: update.name, + message: `Parameter type "${parameter.definition.type}" is not supported for setting via WebMCP.`, + }); + continue; + } + + const prepared = prepareParameterStoreValue(parameter, update.value); + if (!prepared.success) { + errors.push({name: update.name, message: prepared.message}); + continue; + } + + (valuesByNamespace[targetNamespace] ??= {})[paramId] = + prepared.storeValue; + } + + const applied = Object.values(valuesByNamespace).flatMap(Object.keys); + if (applied.length > 0) { + await batchUpdate(valuesByNamespace); + } + + return {applied, errors}; +} diff --git a/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts b/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts new file mode 100644 index 00000000..778964c5 --- /dev/null +++ b/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts @@ -0,0 +1,125 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import type {DecomposedColorFormat} from "@AppBuilderLib/shared/lib/colors"; +import {composeSdColor} from "@AppBuilderLib/shared/lib/colors"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import {toStringListStoreValue} from "../stringListValue"; +import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; + +const COLOR_ON_NON_COLOR_MESSAGE = + "Color object value is only valid for Color parameters."; + +const NUMERIC_TYPES: ResParameterType[] = [ + ResParameterType.INT, + ResParameterType.FLOAT, + ResParameterType.EVEN, + ResParameterType.ODD, +]; + +function isColorObject(value: unknown): value is DecomposedColorFormat { + return ( + typeof value === "object" && + value !== null && + "red" in value && + "green" in value && + "blue" in value && + "alpha" in value + ); +} + +/** + * Type-specific hint appended to the invalid-value message so weak models can self-correct. + * Uses only definition metadata — the validity check stays `parameter.actions.isValid`. + */ +function parameterHint( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): string { + const def = parameter.definition; + const type = def.type as ResParameterType; + if (type === ResParameterType.STRINGLIST) { + const choices = def.choices ?? []; + return ` Use a 0-based integer index (0..${Math.max(choices.length - 1, 0)}). Choices: ${JSON.stringify(choices)}.`; + } + if (NUMERIC_TYPES.includes(type)) { + const min = def.min ?? Number.NEGATIVE_INFINITY; + const max = def.max ?? Number.POSITIVE_INFINITY; + return ` Use a number in range [${min}, ${max}].`; + } + if (type === ResParameterType.COLOR) { + return " Use a color object {red, green, blue, alpha} (0-255)."; + } + if (type === ResParameterType.BOOL) { + return " Use a boolean."; + } + if (type === ResParameterType.STRING) { + return def.max !== undefined + ? ` Use a string of length <= ${def.max}.` + : " Use a string."; + } + // Unknown / unsupported type: surface the canonical validator message + // (parameter.actions.isValid in throw mode) rather than no hint at all. + const detail = canonicalValidatorMessage(parameter, value); + return detail + ? ` ${detail}` + : " Use a value valid for this parameter type."; +} + +/** Extracts the canonical error message from `parameter.actions.isValid` (throw mode). */ +function canonicalValidatorMessage( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): string { + try { + parameter.actions.isValid(value, true); + return ""; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } +} + +function invalidValueMessage( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): string { + const def = parameter.definition; + const name = def.displayname || def.name; + return `Value ${JSON.stringify(value)} is not valid for parameter "${name}" (${def.type}).${parameterHint(parameter, value)}`; +} + +/** + * Validates agent input and returns the store value ShapeDiver expects. + * The validity check is delegated to `parameter.actions.isValid`; only input + * shaping (Color object, StringList index) and agent-friendly error messages + * live here. + */ +export function prepareParameterStoreValue( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): ParameterValuePrepareResult { + const type = parameter.definition.type; + + if (isColorObject(value) && type !== ResParameterType.COLOR) { + return {success: false, message: COLOR_ON_NON_COLOR_MESSAGE}; + } + + let storeValue: unknown; + if (type === ResParameterType.COLOR && isColorObject(value)) { + storeValue = composeSdColor(value); + } else if (type === ResParameterType.STRINGLIST) { + storeValue = toStringListStoreValue(value); + if (storeValue === undefined) { + return { + success: false, + message: invalidValueMessage(parameter, value), + }; + } + } else { + storeValue = value; + } + + if (!parameter.actions.isValid(storeValue, false)) { + return {success: false, message: invalidValueMessage(parameter, value)}; + } + + return {success: true, storeValue}; +} diff --git a/features/webmcp/lib/setParameterValueValidators/types.ts b/features/webmcp/lib/setParameterValueValidators/types.ts new file mode 100644 index 00000000..c0d41a75 --- /dev/null +++ b/features/webmcp/lib/setParameterValueValidators/types.ts @@ -0,0 +1,8 @@ +import type {z} from "zod"; +import {parameterValueSchema} from "../../config/listParameterDefinitions"; + +export type ParameterValueInput = z.infer; + +export type ParameterValuePrepareResult = + | {success: true; storeValue: unknown} + | {success: false; message: string}; diff --git a/features/webmcp/lib/stringListValue.ts b/features/webmcp/lib/stringListValue.ts new file mode 100644 index 00000000..5a5c1c8d --- /dev/null +++ b/features/webmcp/lib/stringListValue.ts @@ -0,0 +1,28 @@ +/** Parse StringList index from store value (string) or agent input (number). */ +export function parseStringListIndex(value: unknown): number | undefined { + if (typeof value === "number" && Number.isInteger(value)) { + return value; + } + if ( + typeof value === "string" && + value !== "" && + !Number.isNaN(Number(value)) + ) { + const index = Number(value); + if (Number.isInteger(index)) { + return index; + } + } + + return undefined; +} + +/** ShapeDiver StringList validator expects string index, not number. */ +export function toStringListStoreValue(value: unknown): string | undefined { + const index = parseStringListIndex(value); + if (index === undefined) { + return undefined; + } + + return String(index); +} diff --git a/features/webmcp/lib/webmcpAvailability.ts b/features/webmcp/lib/webmcpAvailability.ts new file mode 100644 index 00000000..6c7b4132 --- /dev/null +++ b/features/webmcp/lib/webmcpAvailability.ts @@ -0,0 +1,74 @@ +export interface ModelContextToolAnnotations { + readOnlyHint?: boolean; + untrustedContentHint?: boolean; +} + +export interface ModelContextRegisterToolOptions { + signal?: AbortSignal; + exposedTo?: string[]; +} + +export interface ModelContextRegisterToolParams { + name: string; + description: string; + inputSchema: object; + execute: (input: unknown) => Promise; + annotations?: ModelContextToolAnnotations; +} + +export interface ModelContext { + registerTool( + params: ModelContextRegisterToolParams, + options?: ModelContextRegisterToolOptions, + ): Promise; + getTools(): unknown[]; + executeTool(tool: unknown, jsonString: string): Promise; +} + +function getModelContextHost(): + | (Document & {modelContext: ModelContext}) + | (Navigator & {modelContext: ModelContext}) + | undefined { + if (typeof document !== "undefined" && "modelContext" in document) { + return document as Document & {modelContext: ModelContext}; + } + if (typeof navigator !== "undefined" && "modelContext" in navigator) { + return navigator as Navigator & {modelContext: ModelContext}; + } + + return undefined; +} + +export function isWebMcpAvailable(): boolean { + return getModelContextHost() !== undefined; +} + +export function isCrossOriginIsolated(): boolean { + return typeof crossOriginIsolated !== "undefined" && crossOriginIsolated; +} + +export function getWebMcpEnvironment(): { + modelContextAvailable: boolean; + crossOriginIsolated: boolean; + ready: boolean; +} { + const modelContextAvailable = isWebMcpAvailable(); + const coi = isCrossOriginIsolated(); + + return { + modelContextAvailable, + crossOriginIsolated: coi, + ready: modelContextAvailable && coi, + }; +} + +export function getModelContext(): ModelContext { + const host = getModelContextHost(); + if (!host) { + throw new Error( + "WebMCP modelContext is not available in this browser.", + ); + } + + return host.modelContext; +} diff --git a/features/webmcp/lib/zodToJsonSchema.ts b/features/webmcp/lib/zodToJsonSchema.ts new file mode 100644 index 00000000..1bd35dfb --- /dev/null +++ b/features/webmcp/lib/zodToJsonSchema.ts @@ -0,0 +1,148 @@ +import {z} from "zod"; + +/** JSON Schema subset accepted by WebMCP `registerTool({ inputSchema })`. */ +export type JsonSchema = { + type?: string; + description?: string; + properties?: Record; + required?: string[]; + items?: JsonSchema; + enum?: (string | number | boolean)[]; + anyOf?: JsonSchema[]; + additionalProperties?: JsonSchema | boolean; + const?: string | number | boolean; +}; + +/** Zod 4 internal definition shape (not public API). */ +type ZodDef = { + type: string; + shape?: Record; + innerType?: z.ZodType; + options?: z.ZodType[]; + element?: z.ZodType; + entries?: Record; + values?: (string | number | boolean)[]; + valueType?: z.ZodType; +}; + +const PRIMITIVE_TYPES = new Set(["string", "number", "boolean"]); + +function getDef(schema: z.ZodType): ZodDef { + return (schema as z.ZodType & {_zod: {def: ZodDef}})._zod.def; +} + +/** Peel `.optional()` so required[] on parent object stays correct. */ +function unwrapOptional(schema: z.ZodType): { + schema: z.ZodType; + optional: boolean; +} { + const def = getDef(schema); + if (def.type === "optional" && def.innerType) { + return {schema: def.innerType, optional: true}; + } + + return {schema, optional: false}; +} + +function withDescription( + schema: z.ZodType, + jsonSchema: JsonSchema, +): JsonSchema { + if (schema.description) { + jsonSchema.description = schema.description; + } + + return jsonSchema; +} + +function literalSchema(value: unknown): JsonSchema { + if (typeof value === "string") { + return {type: "string", const: value}; + } + if (typeof value === "number") { + return {type: "number", const: value}; + } + if (typeof value === "boolean") { + return {type: "boolean", const: value}; + } + + return {const: value as string | number | boolean}; +} + +/** Builds strict object schema; nested objects also get additionalProperties: false. */ +function objectSchema( + shape: Record, + schema?: z.ZodType, +): JsonSchema { + const properties: Record = {}; + const required: string[] = []; + + for (const [key, fieldSchema] of Object.entries(shape)) { + const {schema: inner, optional} = unwrapOptional(fieldSchema); + properties[key] = zodToJsonSchema(inner); + if (!optional) { + required.push(key); + } + } + + const result: JsonSchema = { + type: "object", + properties, + additionalProperties: false, + }; + if (required.length > 0) { + result.required = required; + } + + return schema ? withDescription(schema, result) : result; +} + +/** + * Converts a Zod schema to JSON Schema for WebMCP tool registration. + * + * Intentionally small: only handles Zod types used by `features/webmcp/config` + * (`strictObject`, primitives, enum, union, array, record, literal). + * Every object node sets `additionalProperties: false` so weak models cannot + * invent extra keys (`parameters`, `visibleOnly`, etc.). + */ +export function zodToJsonSchema(schema: z.ZodType): JsonSchema { + const def = getDef(schema); + + if (PRIMITIVE_TYPES.has(def.type)) { + return withDescription(schema, {type: def.type}); + } + + switch (def.type) { + case "any": + return {}; + case "enum": + return withDescription(schema, { + type: "string", + enum: Object.values(def.entries ?? {}), + }); + case "literal": + return literalSchema(def.values?.[0]); + case "array": + return { + type: "array", + items: def.element ? zodToJsonSchema(def.element) : {}, + }; + case "object": + return objectSchema(def.shape ?? {}, schema); + case "union": + return { + anyOf: (def.options ?? []).map(zodToJsonSchema), + }; + case "record": + return { + type: "object", + additionalProperties: def.valueType + ? zodToJsonSchema(def.valueType) + : true, + }; + case "optional": + return def.innerType ? zodToJsonSchema(def.innerType) : {}; + default: + throw new Error(`Unsupported Zod type: ${def.type}`); + } +} diff --git a/features/webmcp/model/tools/registerCreateModelStateTool.ts b/features/webmcp/model/tools/registerCreateModelStateTool.ts new file mode 100644 index 00000000..0bbd3893 --- /dev/null +++ b/features/webmcp/model/tools/registerCreateModelStateTool.ts @@ -0,0 +1,55 @@ +import {createModelStateInputSchema} from "../../config/createModelState"; +import { + CREATE_MODEL_STATE_TOOL_DESCRIPTION, + CREATE_MODEL_STATE_TOOL_NAME, +} from "../../config/tools"; +import type {ModelContext} from "../../lib/webmcpAvailability"; +import {zodToJsonSchema} from "../../lib/zodToJsonSchema"; +import type {WebMcpToolsDeps} from "../webMcpToolsDeps"; + +export async function registerCreateModelStateTool( + modelContext: ModelContext, + deps: WebMcpToolsDeps, + signal: AbortSignal, +): Promise { + await modelContext.registerTool( + { + name: CREATE_MODEL_STATE_TOOL_NAME, + description: CREATE_MODEL_STATE_TOOL_DESCRIPTION, + inputSchema: zodToJsonSchema(createModelStateInputSchema), + annotations: { + readOnlyHint: false, + untrustedContentHint: true, + }, + execute: async (input) => { + try { + const parsed = createModelStateInputSchema.parse(input); + const result = + await deps.createModelStateRef.current(parsed); + + if (!result.modelStateId) { + return { + success: false as const, + error: "Failed to create model state.", + }; + } + + return { + success: true as const, + modelStateId: result.modelStateId, + modelStateImageUrl: result.modelStateImageUrl, + modelStateGltfUrl: result.modelStateGltfUrl, + modelStateUsdzUrl: result.modelStateUsdzUrl, + modelViewUrl: result.modelViewUrl ?? "", + }; + } catch (e) { + return { + success: false as const, + error: e instanceof Error ? e.message : String(e), + }; + } + }, + }, + {signal}, + ); +} diff --git a/features/webmcp/model/tools/registerImportModelStateTool.ts b/features/webmcp/model/tools/registerImportModelStateTool.ts new file mode 100644 index 00000000..22e8cf53 --- /dev/null +++ b/features/webmcp/model/tools/registerImportModelStateTool.ts @@ -0,0 +1,70 @@ +import {getParameterStates} from "@AppBuilderLib/entities/parameter/lib/parameterStates"; +import {importModelStateInputSchema} from "../../config/importModelState"; +import { + IMPORT_MODEL_STATE_TOOL_DESCRIPTION, + IMPORT_MODEL_STATE_TOOL_NAME, +} from "../../config/tools"; +import {computeAppliedParameterIds} from "../../lib/computeAppliedParameterIds"; +import type {ModelContext} from "../../lib/webmcpAvailability"; +import {zodToJsonSchema} from "../../lib/zodToJsonSchema"; +import type {WebMcpToolsDeps} from "../webMcpToolsDeps"; + +export async function registerImportModelStateTool( + modelContext: ModelContext, + deps: WebMcpToolsDeps, + signal: AbortSignal, +): Promise { + await modelContext.registerTool( + { + name: IMPORT_MODEL_STATE_TOOL_NAME, + description: IMPORT_MODEL_STATE_TOOL_DESCRIPTION, + inputSchema: zodToJsonSchema(importModelStateInputSchema), + annotations: { + readOnlyHint: false, + untrustedContentHint: true, + }, + execute: async (input) => { + try { + const parsed = importModelStateInputSchema.parse(input); + const targetNamespace = deps.namespaceRef.current; + const beforeValues = new Map( + getParameterStates(targetNamespace).map((p) => [ + p.definition.id, + p.state.uiValue, + ]), + ); + const result = + await deps.importModelStateRef.current(parsed); + + if (!result.success) { + return { + success: false as const, + message: result.message, + invalidParameters: result.invalidParameters ?? [], + }; + } + + const appliedParameterIds = computeAppliedParameterIds( + beforeValues, + getParameterStates(targetNamespace), + ); + + return { + success: true as const, + appliedParameterIds, + ...(result.invalidParameters + ? {invalidParameters: result.invalidParameters} + : {}), + }; + } catch (e) { + return { + success: false as const, + message: e instanceof Error ? e.message : String(e), + invalidParameters: [], + }; + } + }, + }, + {signal}, + ); +} diff --git a/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts b/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts new file mode 100644 index 00000000..a4fa31c5 --- /dev/null +++ b/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts @@ -0,0 +1,56 @@ +import {listParameterDefinitionsInputSchema} from "../../config/listParameterDefinitions"; +import { + LIST_PARAMETER_DEFINITIONS_TOOL_DESCRIPTION, + LIST_PARAMETER_DEFINITIONS_TOOL_NAME, +} from "../../config/tools"; +import {formatToolInputError} from "../../lib/formatToolInputError"; +import {mapParameterDefinition} from "../../lib/parameterDefinitionMapper"; +import type {ModelContext} from "../../lib/webmcpAvailability"; +import {zodToJsonSchema} from "../../lib/zodToJsonSchema"; +import type {WebMcpToolsDeps} from "../webMcpToolsDeps"; + +export async function registerListParameterDefinitionsTool( + modelContext: ModelContext, + deps: WebMcpToolsDeps, + signal: AbortSignal, +): Promise { + await modelContext.registerTool( + { + name: LIST_PARAMETER_DEFINITIONS_TOOL_NAME, + description: LIST_PARAMETER_DEFINITIONS_TOOL_DESCRIPTION, + inputSchema: zodToJsonSchema(listParameterDefinitionsInputSchema), + annotations: { + readOnlyHint: true, + untrustedContentHint: true, + }, + execute: async (input) => { + try { + const parsed = + listParameterDefinitionsInputSchema.parse(input); + const filter = parsed.filter ?? "all"; + const targetNamespace = + parsed.sessionId ?? deps.namespaceRef.current; + let parameters = deps.getLiveParameters(targetNamespace); + + if (filter === "visible") { + parameters = parameters.filter( + (p) => !p.definition.hidden, + ); + } + + return { + parameters: parameters.map((param) => + mapParameterDefinition(param), + ), + }; + } catch (e) { + return { + parameters: [], + ...formatToolInputError(e), + }; + } + }, + }, + {signal}, + ); +} diff --git a/features/webmcp/model/tools/registerSetParameterValuesTool.ts b/features/webmcp/model/tools/registerSetParameterValuesTool.ts new file mode 100644 index 00000000..e985cdf7 --- /dev/null +++ b/features/webmcp/model/tools/registerSetParameterValuesTool.ts @@ -0,0 +1,46 @@ +import {setParameterValuesInputSchema} from "../../config/setParameterValues"; +import { + SET_PARAMETER_VALUES_TOOL_DESCRIPTION, + SET_PARAMETER_VALUES_TOOL_NAME, +} from "../../config/tools"; +import {formatToolInputError} from "../../lib/formatToolInputError"; +import {resolveAndUpdate} from "../../lib/resolveSetParameterUpdates"; +import type {ModelContext} from "../../lib/webmcpAvailability"; +import {zodToJsonSchema} from "../../lib/zodToJsonSchema"; +import type {WebMcpToolsDeps} from "../webMcpToolsDeps"; + +export async function registerSetParameterValuesTool( + modelContext: ModelContext, + deps: WebMcpToolsDeps, + signal: AbortSignal, +): Promise { + await modelContext.registerTool( + { + name: SET_PARAMETER_VALUES_TOOL_NAME, + description: SET_PARAMETER_VALUES_TOOL_DESCRIPTION, + inputSchema: zodToJsonSchema(setParameterValuesInputSchema), + annotations: { + readOnlyHint: false, + untrustedContentHint: true, + }, + execute: async (input) => { + try { + const parsed = setParameterValuesInputSchema.parse(input); + + return await resolveAndUpdate( + deps.namespaceRef.current, + deps.getLiveParameters, + parsed.updates, + deps.batchParameterValueUpdateRef.current, + ); + } catch (e) { + return { + applied: [], + ...formatToolInputError(e), + }; + } + }, + }, + {signal}, + ); +} diff --git a/features/webmcp/model/useWebMcpTools.ts b/features/webmcp/model/useWebMcpTools.ts new file mode 100644 index 00000000..5227a63e --- /dev/null +++ b/features/webmcp/model/useWebMcpTools.ts @@ -0,0 +1,156 @@ +import {useShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/model/useShapeDiverStoreParameters"; +import {useShapeDiverStoreSession} from "@AppBuilderLib/entities/session/model/useShapeDiverStoreSession"; +import {useCreateModelState} from "@AppBuilderLib/features/model-state/model/useCreateModelState"; +import {useImportModelState} from "@AppBuilderLib/features/model-state/model/useImportModelState"; +import {useEffect, useRef, useState} from "react"; +import {useShallow} from "zustand/react/shallow"; +import { + getModelContext, + getWebMcpEnvironment, + isWebMcpAvailable, +} from "../lib/webmcpAvailability"; +import {registerCreateModelStateTool} from "./tools/registerCreateModelStateTool"; +import {registerImportModelStateTool} from "./tools/registerImportModelStateTool"; +import {registerListParameterDefinitionsTool} from "./tools/registerListParameterDefinitionsTool"; +import {registerSetParameterValuesTool} from "./tools/registerSetParameterValuesTool"; +import type { + UseWebMcpToolsProps, + UseWebMcpToolsResult, +} from "./useWebMcpTools.types"; +import type {WebMcpToolsDeps} from "./webMcpToolsDeps"; + +export function useWebMcpTools( + props: UseWebMcpToolsProps, +): UseWebMcpToolsResult { + const {namespace, enabled = isWebMcpAvailable()} = props; + const [registered, setRegistered] = useState(false); + const environment = getWebMcpEnvironment(); + const ready = registered && environment.ready; + + const {sessions} = useShapeDiverStoreSession( + useShallow((state) => ({ + sessions: state.sessions, + })), + ); + + const {getParameters, batchParameterValueUpdate} = + useShapeDiverStoreParameters( + useShallow((state) => ({ + getParameters: state.getParameters, + batchParameterValueUpdate: state.batchParameterValueUpdate, + })), + ); + + const {createModelState} = useCreateModelState({ + namespace: namespace ?? "", + }); + const {importModelState} = useImportModelState({ + namespace: namespace ?? "", + }); + const namespaceRef = useRef(namespace ?? ""); + namespaceRef.current = namespace ?? ""; + + const getParametersRef = useRef(getParameters); + getParametersRef.current = getParameters; + + const batchParameterValueUpdateRef = useRef(batchParameterValueUpdate); + batchParameterValueUpdateRef.current = batchParameterValueUpdate; + + const createModelStateRef = useRef(createModelState); + createModelStateRef.current = createModelState; + + const importModelStateRef = useRef(importModelState); + importModelStateRef.current = importModelState; + + const sessionReady = !!namespace && !!sessions[namespace]; + const paramsPopulated = + !!namespace && Object.keys(getParameters(namespace)).length > 0; + + useEffect(() => { + if (enabled === false || !isWebMcpAvailable()) { + setRegistered(false); + return; + } + + if (!sessionReady || !paramsPopulated) { + setRegistered(false); + return; + } + + const controller = new AbortController(); + let cancelled = false; + + const deps: WebMcpToolsDeps = { + namespaceRef, + getLiveParameters: (targetNamespace) => + Object.values(getParametersRef.current(targetNamespace)).map( + (store) => store.getState(), + ), + batchParameterValueUpdateRef, + createModelStateRef, + importModelStateRef, + }; + + const registerTools = async () => { + const modelContext = getModelContext(); + + try { + await registerListParameterDefinitionsTool( + modelContext, + deps, + controller.signal, + ); + await registerSetParameterValuesTool( + modelContext, + deps, + controller.signal, + ); + await registerCreateModelStateTool( + modelContext, + deps, + controller.signal, + ); + await registerImportModelStateTool( + modelContext, + deps, + controller.signal, + ); + + if (!cancelled) { + setRegistered(true); + } + } catch { + if (!cancelled) { + setRegistered(false); + } + } + }; + + void registerTools(); + + return () => { + cancelled = true; + controller.abort(); + setRegistered(false); + }; + }, [enabled, namespace, sessionReady, paramsPopulated]); + + const environmentSnapshot = { + modelContextAvailable: environment.modelContextAvailable, + crossOriginIsolated: environment.crossOriginIsolated, + }; + + if (enabled === false || !isWebMcpAvailable()) { + return { + registered: false, + ready: false, + environment: environmentSnapshot, + }; + } + + return { + registered, + ready, + environment: environmentSnapshot, + }; +} diff --git a/features/webmcp/model/useWebMcpTools.types.ts b/features/webmcp/model/useWebMcpTools.types.ts new file mode 100644 index 00000000..42705cb8 --- /dev/null +++ b/features/webmcp/model/useWebMcpTools.types.ts @@ -0,0 +1,17 @@ +export interface UseWebMcpToolsProps { + namespace?: string; + enabled?: boolean; +} + +export interface WebMcpEnvironment { + modelContextAvailable: boolean; + crossOriginIsolated: boolean; +} + +export interface UseWebMcpToolsResult { + /** Tools registered on `modelContext` (registration may succeed without COI). */ + registered: boolean; + /** Tools callable by agents: `registered` and cross-origin isolated with `modelContext`. */ + ready: boolean; + environment: WebMcpEnvironment; +} diff --git a/features/webmcp/model/webMcpToolsDeps.ts b/features/webmcp/model/webMcpToolsDeps.ts new file mode 100644 index 00000000..2acea0dc --- /dev/null +++ b/features/webmcp/model/webMcpToolsDeps.ts @@ -0,0 +1,25 @@ +import type {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import type {IShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/config/shapediverStoreParameters"; +import type { + ICreateModelStateData, + ICreateModelStateResult, +} from "@AppBuilderLib/features/model-state/config/createModelState"; +import type { + IImportModelStateData, + IImportModelStateResult, +} from "@AppBuilderLib/features/model-state/config/importModelState"; +import type {MutableRefObject} from "react"; + +export interface WebMcpToolsDeps { + namespaceRef: MutableRefObject; + getLiveParameters: (namespace: string) => IShapeDiverParameter[]; + batchParameterValueUpdateRef: MutableRefObject< + IShapeDiverStoreParameters["batchParameterValueUpdate"] + >; + createModelStateRef: MutableRefObject< + (props: ICreateModelStateData) => Promise + >; + importModelStateRef: MutableRefObject< + (props: IImportModelStateData) => Promise + >; +} diff --git a/pages/appbuilder/AppBuilderPage.tsx b/pages/appbuilder/AppBuilderPage.tsx index 45f0e3ce..ff7b68f0 100644 --- a/pages/appbuilder/AppBuilderPage.tsx +++ b/pages/appbuilder/AppBuilderPage.tsx @@ -11,6 +11,8 @@ import {useKeyBindings} from "@AppBuilderLib/features/appbuilder/model/useKeyBin import {useSessionWithAppBuilder} from "@AppBuilderLib/features/appbuilder/model/useSessionWithAppBuilder"; import {useECommerceApiConnectorActions} from "@AppBuilderLib/features/ecommerce/model/useECommerceApiConnectorActions"; import NotificationModelStateCreated from "@AppBuilderLib/features/notifications/ui/NotificationModelStateCreated"; +import {isWebMcpAvailable} from "@AppBuilderLib/features/webmcp/lib/webmcpAvailability"; +import {useWebMcpTools} from "@AppBuilderLib/features/webmcp/model/useWebMcpTools"; import {shouldUsePlatform} from "@AppBuilderLib/shared/lib/platform/environment"; import {useShapeDiverStorePlatform} from "@AppBuilderLib/shared/model/useShapeDiverStorePlatform"; import MarkdownWidgetComponent from "@AppBuilderLib/shared/ui/markdown/MarkdownWidgetComponent"; @@ -225,6 +227,11 @@ export default function AppBuilderPage(props: Partial) { useECommerceApiConnectorActions({namespace}); + useWebMcpTools({ + namespace, + enabled: isWebMcpAvailable(), + }); + const showMarkdown = !(settings && hasSession) && // no settings or no session !loading && // not loading