From ff1e2d0387958bb00e66ed5bb1162a303bd7355d Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Thu, 9 Jul 2026 16:04:32 +0300 Subject: [PATCH 01/20] SS-9745: Add web mcp hooks --- .../__tests__/filterVisibleParameters.test.ts | 35 ++ .../parameterDefinitionMapper.test.ts | 161 ++++++++ .../setParameterValues.feedback.test.ts | 261 ++++++++++++ .../webmcp/__tests__/webmcpSchemas.test.ts | 98 +++++ .../webmcp/__tests__/zodToJsonSchema.test.ts | 70 ++++ features/webmcp/config/createModelState.ts | 50 +++ features/webmcp/config/importModelState.ts | 37 ++ .../webmcp/config/listParameterDefinitions.ts | 72 ++++ features/webmcp/config/setParameterValues.ts | 260 ++++++++++++ features/webmcp/config/tools.ts | 40 ++ .../webmcp/evals/__fixtures__/parameters.ts | 179 ++++++++ features/webmcp/evals/evals.json | 258 ++++++++++++ features/webmcp/evals/runWebmcpEvals.ts | 277 +++++++++++++ .../webmcp/lib/filterVisibleParameters.ts | 31 ++ .../webmcp/lib/parameterDefinitionMapper.ts | 72 ++++ features/webmcp/lib/stringListValue.ts | 28 ++ features/webmcp/lib/webmcpAvailability.ts | 55 +++ features/webmcp/lib/zodToJsonSchema.ts | 135 ++++++ features/webmcp/model/useWebMcpTools.ts | 387 ++++++++++++++++++ features/webmcp/model/useWebMcpTools.types.ts | 8 + pages/appbuilder/AppBuilderPage.tsx | 7 + 21 files changed, 2521 insertions(+) create mode 100644 features/webmcp/__tests__/filterVisibleParameters.test.ts create mode 100644 features/webmcp/__tests__/parameterDefinitionMapper.test.ts create mode 100644 features/webmcp/__tests__/setParameterValues.feedback.test.ts create mode 100644 features/webmcp/__tests__/webmcpSchemas.test.ts create mode 100644 features/webmcp/__tests__/zodToJsonSchema.test.ts create mode 100644 features/webmcp/config/createModelState.ts create mode 100644 features/webmcp/config/importModelState.ts create mode 100644 features/webmcp/config/listParameterDefinitions.ts create mode 100644 features/webmcp/config/setParameterValues.ts create mode 100644 features/webmcp/config/tools.ts create mode 100644 features/webmcp/evals/__fixtures__/parameters.ts create mode 100644 features/webmcp/evals/evals.json create mode 100644 features/webmcp/evals/runWebmcpEvals.ts create mode 100644 features/webmcp/lib/filterVisibleParameters.ts create mode 100644 features/webmcp/lib/parameterDefinitionMapper.ts create mode 100644 features/webmcp/lib/stringListValue.ts create mode 100644 features/webmcp/lib/webmcpAvailability.ts create mode 100644 features/webmcp/lib/zodToJsonSchema.ts create mode 100644 features/webmcp/model/useWebMcpTools.ts create mode 100644 features/webmcp/model/useWebMcpTools.types.ts diff --git a/features/webmcp/__tests__/filterVisibleParameters.test.ts b/features/webmcp/__tests__/filterVisibleParameters.test.ts new file mode 100644 index 00000000..683a2bad --- /dev/null +++ b/features/webmcp/__tests__/filterVisibleParameters.test.ts @@ -0,0 +1,35 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import {filterVisibleParameters} from "../lib/filterVisibleParameters"; + +function mockParam(id: string, name: string): IShapeDiverParameter { + return { + definition: { + id, + name, + type: ResParameterType.FLOAT, + }, + } as IShapeDiverParameter; +} + +describe("filterVisibleParameters", () => { + it("returns all parameters when accordion refs are empty", () => { + const params = [mockParam("a", "Slider1"), mockParam("b", "Slider2")]; + + expect(filterVisibleParameters(params, [])).toHaveLength(2); + }); + + it("filters by ref name, id, or displayname", () => { + const params = [ + mockParam("id-1", "Slider1"), + mockParam("id-2", "Hidden"), + ]; + + const filtered = filterVisibleParameters(params, [ + {name: "Slider1"}, + {name: "id-2"}, + ]); + + expect(filtered.map((p) => p.definition.id)).toEqual(["id-1", "id-2"]); + }); +}); diff --git a/features/webmcp/__tests__/parameterDefinitionMapper.test.ts b/features/webmcp/__tests__/parameterDefinitionMapper.test.ts new file mode 100644 index 00000000..1adf54fe --- /dev/null +++ b/features/webmcp/__tests__/parameterDefinitionMapper.test.ts @@ -0,0 +1,161 @@ +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, + 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, + 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, + 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, + currentValue: true, + defaultValue: false, + }); + }); + + it("applies ref overrides for displayname/group/tooltip", () => { + 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, { + name: "width", + overrides: { + displayname: "Custom Width", + group: {name: "Sizing"}, + tooltip: "Override tooltip", + }, + }), + ).toEqual({ + id: "p1", + name: "Width", + displayname: "Custom Width", + type: ResParameterType.INT, + group: "Sizing", + tooltip: "Override tooltip", + min: 0, + max: 10, + currentValue: 7, + defaultValue: 5, + }); + }); +}); diff --git a/features/webmcp/__tests__/setParameterValues.feedback.test.ts b/features/webmcp/__tests__/setParameterValues.feedback.test.ts new file mode 100644 index 00000000..18e9abab --- /dev/null +++ b/features/webmcp/__tests__/setParameterValues.feedback.test.ts @@ -0,0 +1,261 @@ +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 "../config/setParameterValues"; + +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); + + beforeEach(() => { + batchParameterValueUpdate.mockClear(); + }); + + it("returns error for unknown parameter", async () => { + const result = await resolveAndUpdate( + 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, + [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, + [param], + [{name: "Width", value: 200}], + batchParameterValueUpdate, + ); + + expect(result).toEqual({ + applied: [], + errors: [ + { + name: "Width", + message: "Value 200 is out of 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, + [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, + [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, + [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, + [param], + [{name: "Material", value: 1}], + batchParameterValueUpdate, + ); + + expect(isValid).toHaveBeenCalledWith("1", false); + expect(result).toEqual({applied: ["material"], errors: []}); + expect(batchParameterValueUpdate).toHaveBeenCalledWith({ + [namespace]: {material: "1"}, + }); + }); +}); diff --git a/features/webmcp/__tests__/webmcpSchemas.test.ts b/features/webmcp/__tests__/webmcpSchemas.test.ts new file mode 100644 index 00000000..779b4a4e --- /dev/null +++ b/features/webmcp/__tests__/webmcpSchemas.test.ts @@ -0,0 +1,98 @@ +import {createModelStateInputSchema} from "../config/createModelState"; +import {importModelStateInputSchema} from "../config/importModelState"; +import {listParameterDefinitionsInputSchema} from "../config/listParameterDefinitions"; +import {setParameterValuesInputSchema} from "../config/setParameterValues"; + +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("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(); + }); + }); +}); 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..7dd02c0f --- /dev/null +++ b/features/webmcp/config/createModelState.ts @@ -0,0 +1,50 @@ +import {z} from "zod"; + +export const createModelStateInputSchema = 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."), + data: z + .record(z.string(), z.any()) + .optional() + .describe("Optional custom metadata to store with the state."), +}); + +export const createModelStateSuccessOutputSchema = z.object({ + success: z.literal(true), + modelStateId: z.string(), + modelStateImageUrl: z.string().optional(), + modelStateGltfUrl: z.string().optional(), + modelStateUsdzUrl: z.string().optional(), + modelViewUrl: z.string(), +}); + +export const createModelStateFailureOutputSchema = z.object({ + success: z.literal(false), + error: z.string(), +}); + +export const createModelStateOutputSchema = z.union([ + createModelStateSuccessOutputSchema, + createModelStateFailureOutputSchema, +]); + +export type CreateModelStateInput = z.infer; +export type CreateModelStateOutput = z.infer< + typeof createModelStateOutputSchema +>; diff --git a/features/webmcp/config/importModelState.ts b/features/webmcp/config/importModelState.ts new file mode 100644 index 00000000..fa04176f --- /dev/null +++ b/features/webmcp/config/importModelState.ts @@ -0,0 +1,37 @@ +import {z} from "zod"; + +export const importModelStateInputSchema = z.strictObject({ + modelStateId: z + .string() + .describe( + "modelStateId from create_model_state, or a full model view URL containing modelStateId.", + ), +}); + +export const importModelStateInvalidParameterSchema = z.object({ + name: z.string(), + message: z.string(), +}); + +export const importModelStateSuccessOutputSchema = z.object({ + success: z.literal(true), + appliedParameterIds: z.array(z.string()), +}); + +export const importModelStateFailureOutputSchema = z.object({ + success: z.literal(false), + message: z.string(), + invalidParameters: z + .array(importModelStateInvalidParameterSchema) + .optional(), +}); + +export const importModelStateOutputSchema = z.union([ + importModelStateSuccessOutputSchema, + importModelStateFailureOutputSchema, +]); + +export type ImportModelStateInput = z.infer; +export type ImportModelStateOutput = z.infer< + typeof importModelStateOutputSchema +>; diff --git a/features/webmcp/config/listParameterDefinitions.ts b/features/webmcp/config/listParameterDefinitions.ts new file mode 100644 index 00000000..98a6e203 --- /dev/null +++ b/features/webmcp/config/listParameterDefinitions.ts @@ -0,0 +1,72 @@ +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import {z} from "zod"; + +export 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, +]); + +export 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(), +}); + +export const listParameterDefinitionsInputSchema = z.strictObject({ + filter: z + .enum(["all", "visible"]) + .optional() + .describe( + "all = every changeable parameter; visible = parameters shown in the configurator panel. 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), +}); + +export type ListParameterDefinitionItem = z.infer< + typeof ListParameterDefinitionItemSchema +>; +export type ListParameterDefinitionsInput = z.infer< + typeof listParameterDefinitionsInputSchema +>; +export type ListParameterDefinitionsOutput = z.infer< + typeof listParameterDefinitionsOutputSchema +>; + +/** 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..57913721 --- /dev/null +++ b/features/webmcp/config/setParameterValues.ts @@ -0,0 +1,260 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import type {IShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/config/shapediverStoreParameters"; +import { + composeSdColor, + type DecomposedColorFormat, +} from "@AppBuilderLib/shared/lib/colors"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import {z} from "zod"; +import { + parseStringListIndex, + toStringListStoreValue, +} from "../lib/stringListValue"; +import {parameterValueSchema} from "./listParameterDefinitions"; + +export const setParameterValuesInputSchema = z.strictObject({ + updates: z + .array( + 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}.", + ), + }), + ) + .describe( + "Required array of changes. Use this field name exactly — not parameters or ids.", + ), +}); + +export const setParameterValuesErrorSchema = z.object({ + name: z.string(), + message: z.string(), +}); + +export const setParameterValuesOutputSchema = z.object({ + applied: z.array(z.string()), + errors: z.array(setParameterValuesErrorSchema), +}); + +export type SetParameterValuesInput = z.infer< + typeof setParameterValuesInputSchema +>; +export type SetParameterValuesOutput = z.infer< + typeof setParameterValuesOutputSchema +>; +export type SetParameterValuesError = z.infer< + typeof setParameterValuesErrorSchema +>; +export type ParameterUpdateInput = SetParameterValuesInput["updates"][number]; +export type ParameterValueInput = z.infer; + +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 + ); +} + +function findParameter( + parameters: IShapeDiverParameter[], + name: string, +): IShapeDiverParameter | undefined { + return parameters.find( + (p) => + p.definition.id === name || + p.definition.name === name || + p.definition.displayname === name, + ); +} + +function getValidationErrorMessage( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): string { + const def = parameter.definition; + const type = def.type; + + if (isColorObject(value) && type !== ResParameterType.COLOR) { + return "Color object value is only valid for Color parameters."; + } + + if (type === ResParameterType.STRINGLIST) { + const choices = def.choices ?? []; + const index = parseStringListIndex(value); + if (index === undefined) { + return `Value type does not match parameter type ${type}. Use a 0-based integer index.`; + } + + return `Index ${index} is not valid (choices: 0..${Math.max(choices.length - 1, 0)}).`; + } + + if (type === ResParameterType.COLOR) { + if (!isColorObject(value)) { + return `Value type does not match parameter type ${type}.`; + } + + return `New color ${JSON.stringify(value)} is not valid for parameter.`; + } + + if ( + type === ResParameterType.EVEN || + type === ResParameterType.ODD || + type === ResParameterType.INT || + type === ResParameterType.FLOAT + ) { + if (typeof value !== "number") { + return `Value type does not match parameter type ${type}.`; + } + const min = def.min ?? null; + const max = def.max ?? null; + + return `Value ${value} is out of range [${min}, ${max}].`; + } + + if (type === ResParameterType.STRING) { + if (typeof value !== "string") { + return `Value type does not match parameter type ${type}.`; + } + + return `String value exceeds maximum length of ${def.max}.`; + } + + if (type === ResParameterType.BOOL) { + if (typeof value !== "boolean") { + return `Value type does not match parameter type ${type}.`; + } + } + + return `Value ${value} is not valid for parameter.`; +} + +/** + * Pure validation and batch-update logic for set_parameter_values. + */ +export async function resolveAndUpdate( + namespace: string, + parameters: IShapeDiverParameter[], + updates: ParameterUpdateInput[], + batchUpdate: IShapeDiverStoreParameters["batchParameterValueUpdate"], +): Promise { + const errors: SetParameterValuesError[] = []; + const valuesByNamespace: Record> = {}; + const processedIds = new Set(); + + for (const update of updates) { + const parameter = findParameter(parameters, 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); + + const def = parameter.definition; + const {value} = update; + + if (isColorObject(value) && def.type !== ResParameterType.COLOR) { + errors.push({ + name: update.name, + message: + "Color object value is only valid for Color parameters.", + }); + continue; + } + + let preparedValue: unknown; + if (def.type === ResParameterType.COLOR) { + if (!isColorObject(value)) { + errors.push({ + name: update.name, + message: `Value type does not match parameter type ${def.type}.`, + }); + continue; + } + preparedValue = composeSdColor(value); + if (!parameter.actions.isValid(preparedValue, false)) { + errors.push({ + name: update.name, + message: getValidationErrorMessage(parameter, value), + }); + continue; + } + } else if (def.type === ResParameterType.STRINGLIST) { + const storeValue = toStringListStoreValue(value); + if (storeValue === undefined) { + errors.push({ + name: update.name, + message: `Value type does not match parameter type ${def.type}. Use a 0-based integer index.`, + }); + continue; + } + if (!parameter.actions.isValid(storeValue, false)) { + errors.push({ + name: update.name, + message: getValidationErrorMessage(parameter, value), + }); + continue; + } + preparedValue = storeValue; + } else { + if (isColorObject(value)) { + errors.push({ + name: update.name, + message: + "Color object value is only valid for Color parameters.", + }); + continue; + } + if (!parameter.actions.isValid(value, false)) { + errors.push({ + name: update.name, + message: getValidationErrorMessage(parameter, value), + }); + continue; + } + preparedValue = value; + } + + const targetNamespace = update.sessionId ?? namespace; + if (!valuesByNamespace[targetNamespace]) { + valuesByNamespace[targetNamespace] = {}; + } + valuesByNamespace[targetNamespace][paramId] = preparedValue; + } + + const applied = Object.values(valuesByNamespace).flatMap((values) => + Object.keys(values), + ); + + if (applied.length > 0) { + await batchUpdate(valuesByNamespace); + } + + return {applied, errors}; +} diff --git a/features/webmcp/config/tools.ts b/features/webmcp/config/tools.ts new file mode 100644 index 00000000..54336ae2 --- /dev/null +++ b/features/webmcp/config/tools.ts @@ -0,0 +1,40 @@ +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: [...] } with id, name, type, choices/min/max, currentValue. " + + "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[] } — parameter ids that changed during import (not a field named applied). " + + "If no diff is detected, all parameter ids may be listed. " + + "Failure: { success: false, message: string }. " + + "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..f4cec63e --- /dev/null +++ b/features/webmcp/evals/__fixtures__/parameters.ts @@ -0,0 +1,179 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {IAppBuilderParameterRef} from "@AppBuilderLib/features/appbuilder/config/appbuilder"; +import {composeSdColor} from "@AppBuilderLib/shared/lib/colors"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; + +export const EVAL_NAMESPACE = "eval-session"; + +function createIsValid( + def: IShapeDiverParameter["definition"], +): IShapeDiverParameter["actions"]["isValid"] { + return (value: unknown) => { + const type = def.type; + + if ( + type === ResParameterType.INT || + type === ResParameterType.FLOAT || + type === ResParameterType.EVEN || + type === ResParameterType.ODD + ) { + if (typeof value !== "number") { + return false; + } + const min = def.min ?? Number.NEGATIVE_INFINITY; + const max = def.max ?? Number.POSITIVE_INFINITY; + + return value >= min && value <= max; + } + + if (type === ResParameterType.STRINGLIST) { + if (typeof value !== "string" && typeof value !== "number") { + return false; + } + const index = typeof value === "number" ? value : Number(value); + const choices = def.choices ?? []; + + return ( + Number.isInteger(index) && index >= 0 && index < choices.length + ); + } + + if (type === ResParameterType.COLOR) { + return typeof value === "string" && value.length > 0; + } + + if (type === ResParameterType.STRING) { + if (typeof value !== "string") { + return false; + } + if (def.max !== undefined) { + return value.length <= def.max; + } + + return true; + } + + if (type === ResParameterType.BOOL) { + return typeof value === "boolean"; + } + + return true; + }; +} + +function createMockParameter( + overrides: Partial> & { + definition: IShapeDiverParameter["definition"]; + }, +): IShapeDiverParameter { + const isValid = createIsValid(overrides.definition); + + return { + state: { + uiValue: overrides.state?.uiValue, + execValue: overrides.state?.execValue, + dirty: false, + disableOtherParameters: false, + stringExecValue: () => "", + }, + actions: { + setUiValue: () => true, + setUiAndExecValue: () => true, + execute: async () => "", + isValid, + 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"], +}); + +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"], +}); + +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 allParameters: IShapeDiverParameter[] = [ + intParameter, + stringListParameter, + colorParameter, + floatParameter, + boolParameter, + colorNamedStringListParameter, +]; + +/** Refs for the "visible" list filter — width + paint only. */ +export const parameterRefs: IAppBuilderParameterRef[] = [ + {name: "width-int"}, + {name: "Paint"}, +]; diff --git a/features/webmcp/evals/evals.json b/features/webmcp/evals/evals.json new file mode 100644 index 00000000..269608d2 --- /dev/null +++ b/features/webmcp/evals/evals.json @@ -0,0 +1,258 @@ +[ + { + "id": "list_all", + "tool": "list_parameter_definitions", + "description": "List all supported parameters", + "input": {"filter": "all"}, + "expect": {"parametersCount": 6} + }, + { + "id": "list_visible", + "tool": "list_parameter_definitions", + "description": "List only UI-visible parameters", + "input": {"filter": "visible"}, + "expect": {"parametersCount": 2, "parameterIds": ["width-int", "paint-color"]} + }, + { + "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"]} + }, + { + "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": "does not match parameter type" + } + }, + { + "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", "out of 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/runWebmcpEvals.ts b/features/webmcp/evals/runWebmcpEvals.ts new file mode 100644 index 00000000..1e5e3d9a --- /dev/null +++ b/features/webmcp/evals/runWebmcpEvals.ts @@ -0,0 +1,277 @@ +/// +import {readFileSync} from "fs"; +import {dirname, join} from "path"; +import {fileURLToPath} from "url"; +import {z} from "zod"; +import {createModelStateInputSchema} from "../config/createModelState"; +import {importModelStateInputSchema} from "../config/importModelState"; +import { + listParameterDefinitionsInputSchema, + SUPPORTED_PARAMETER_TYPES, +} from "../config/listParameterDefinitions"; +import { + resolveAndUpdate, + setParameterValuesInputSchema, +} from "../config/setParameterValues"; +import {filterVisibleParameters} from "../lib/filterVisibleParameters"; +import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; +import { + allParameters, + EVAL_NAMESPACE, + parameterRefs, +} from "./__fixtures__/parameters"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +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; +} + +interface EvalScenario { + id: string; + tool: string; + description: string; + input: Record; + expect: EvalExpect; +} + +function loadScenarios(): EvalScenario[] { + const raw = readFileSync(join(__dirname, "evals.json"), "utf8"); + + return JSON.parse(raw) as EvalScenario[]; +} + +function findParameterRef(param: (typeof allParameters)[number]) { + const def = param.definition; + + return parameterRefs.find( + (ref) => + ref.name === def.id || + ref.name === def.name || + (!!def.displayname && ref.name === def.displayname), + ); +} + +function runListScenario(input: Record) { + const parsed = listParameterDefinitionsInputSchema.parse(input); + const filter = parsed.filter ?? "all"; + let parameters = allParameters.filter((p) => + SUPPORTED_PARAMETER_TYPES.includes(p.definition.type), + ); + + if (filter === "visible") { + parameters = filterVisibleParameters(parameters, parameterRefs); + } + + return parameters.map((param) => + mapParameterDefinition(param, findParameterRef(param)), + ); +} + +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) { + return assertInputSchemaReject( + listParameterDefinitionsInputSchema, + scenario.input, + ); + } + + const parameters = runListScenario(scenario.input); + 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, + 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); + } + + if (expect.success === true) { + console.log(` schema-valid (mocked execution)`); + } + + return null; +} + +async function runScenario(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}"`; + } +} + +async function main() { + const scenarios = loadScenarios(); + let failed = 0; + + for (const scenario of scenarios) { + const reason = await runScenario(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/filterVisibleParameters.ts b/features/webmcp/lib/filterVisibleParameters.ts new file mode 100644 index 00000000..eb22b849 --- /dev/null +++ b/features/webmcp/lib/filterVisibleParameters.ts @@ -0,0 +1,31 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {IAppBuilderParameterRef} from "@AppBuilderLib/features/appbuilder/config/appbuilder"; + +function parameterMatchesRef( + param: IShapeDiverParameter, + refs: IAppBuilderParameterRef[], +): boolean { + const def = param.definition; + + return refs.some( + (ref) => + ref.name === def.id || + ref.name === def.name || + (!!def.displayname && ref.name === def.displayname), + ); +} + +/** + * Parameters shown in accordion widgets. When layout has no accordion refs, + * returns all parameters (no custom UI layout to filter against). + */ +export function filterVisibleParameters( + parameters: IShapeDiverParameter[], + refs: IAppBuilderParameterRef[], +): IShapeDiverParameter[] { + if (refs.length === 0) { + return parameters; + } + + return parameters.filter((param) => parameterMatchesRef(param, refs)); +} diff --git a/features/webmcp/lib/parameterDefinitionMapper.ts b/features/webmcp/lib/parameterDefinitionMapper.ts new file mode 100644 index 00000000..099820f7 --- /dev/null +++ b/features/webmcp/lib/parameterDefinitionMapper.ts @@ -0,0 +1,72 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {IAppBuilderParameterRef} from "@AppBuilderLib/features/appbuilder/config/appbuilder"; +import {decomposeSdColor} from "@AppBuilderLib/shared/lib/colors"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import type {ListParameterDefinitionItem} from "../config/listParameterDefinitions"; +import {parseStringListIndex} from "./stringListValue"; + +export type {ListParameterDefinitionItem}; + +export function mapParameterDefinition( + param: IShapeDiverParameter, + ref?: IAppBuilderParameterRef, +): 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, + }; + + if (def.hidden !== undefined) { + item.hidden = def.hidden; + } + + const displayname = ref?.overrides?.displayname; + if (displayname && displayname !== name) { + item.displayname = displayname; + } + + const groupName = ref?.overrides?.group?.name || def.group?.name; + if (groupName) { + item.group = groupName; + } + + const tooltip = ref?.overrides?.tooltip || def.tooltip; + if (tooltip) { + item.tooltip = 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/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..caa95e0e --- /dev/null +++ b/features/webmcp/lib/webmcpAvailability.ts @@ -0,0 +1,55 @@ +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 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..ed09564a --- /dev/null +++ b/features/webmcp/lib/zodToJsonSchema.ts @@ -0,0 +1,135 @@ +import {z} from "zod"; + +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; +}; + +type ZodDef = { + type: string; + shape?: Record; + innerType?: z.ZodType; + options?: z.ZodType[]; + element?: z.ZodType; + entries?: Record; + values?: (string | number | boolean)[]; + keyType?: z.ZodType; + valueType?: z.ZodType; +}; + +function getDef(schema: z.ZodType): ZodDef { + return (schema as z.ZodType & {_zod: {def: ZodDef}})._zod.def; +} + +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 zodToJsonSchemaInner(schema: z.ZodType): JsonSchema { + const def = getDef(schema); + + switch (def.type) { + case "string": + return withDescription(schema, {type: "string"}); + case "number": + return withDescription(schema, {type: "number"}); + case "boolean": + return withDescription(schema, {type: "boolean"}); + case "any": + return {}; + case "enum": + return withDescription(schema, { + type: "string", + enum: Object.values(def.entries ?? {}), + }); + case "literal": { + const value = def.values?.[0]; + 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}; + } + case "array": + return { + type: "array", + items: def.element ? zodToJsonSchemaInner(def.element) : {}, + }; + case "object": { + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(def.shape ?? {})) { + const {schema: inner, optional} = unwrapOptional(value); + properties[key] = zodToJsonSchemaInner(inner); + if (!optional) { + required.push(key); + } + } + + const result: JsonSchema = { + type: "object", + properties, + additionalProperties: false, + }; + if (required.length > 0) { + result.required = required; + } + + return withDescription(schema, result); + } + case "union": + return { + anyOf: (def.options ?? []).map((option) => + zodToJsonSchemaInner(option), + ), + }; + case "record": + return { + type: "object", + additionalProperties: def.valueType + ? zodToJsonSchemaInner(def.valueType) + : true, + }; + case "optional": + return def.innerType ? zodToJsonSchemaInner(def.innerType) : {}; + default: + throw new Error(`Unsupported Zod type: ${def.type}`); + } +} + +export function zodToJsonSchema(schema: z.ZodType): JsonSchema { + return zodToJsonSchemaInner(schema); +} diff --git a/features/webmcp/model/useWebMcpTools.ts b/features/webmcp/model/useWebMcpTools.ts new file mode 100644 index 00000000..f68000e8 --- /dev/null +++ b/features/webmcp/model/useWebMcpTools.ts @@ -0,0 +1,387 @@ +import {getParameterStates} from "@AppBuilderLib/entities/parameter/lib/parameterStates"; +import {useShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/model/useShapeDiverStoreParameters"; +import {useShapeDiverStoreSession} from "@AppBuilderLib/entities/session/model/useShapeDiverStoreSession"; +import {AppBuilderDataContext} from "@AppBuilderLib/features/appbuilder/lib/AppBuilderContext"; +import {getParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; +import {useCreateModelState} from "@AppBuilderLib/features/model-state/model/useCreateModelState"; +import {useImportModelState} from "@AppBuilderLib/features/model-state/model/useImportModelState"; +import {useContext, useEffect, useRef, useState} from "react"; +import {useShallow} from "zustand/react/shallow"; +import {createModelStateInputSchema} from "../config/createModelState"; +import {importModelStateInputSchema} from "../config/importModelState"; +import { + listParameterDefinitionsInputSchema, + SUPPORTED_PARAMETER_TYPES, +} from "../config/listParameterDefinitions"; +import { + resolveAndUpdate, + setParameterValuesInputSchema, +} from "../config/setParameterValues"; +import { + CREATE_MODEL_STATE_TOOL_DESCRIPTION, + CREATE_MODEL_STATE_TOOL_NAME, + IMPORT_MODEL_STATE_TOOL_DESCRIPTION, + IMPORT_MODEL_STATE_TOOL_NAME, + LIST_PARAMETER_DEFINITIONS_TOOL_DESCRIPTION, + LIST_PARAMETER_DEFINITIONS_TOOL_NAME, + SET_PARAMETER_VALUES_TOOL_DESCRIPTION, + SET_PARAMETER_VALUES_TOOL_NAME, +} from "../config/tools"; +import {filterVisibleParameters} from "../lib/filterVisibleParameters"; +import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; +import {getModelContext, isWebMcpAvailable} from "../lib/webmcpAvailability"; +import {zodToJsonSchema} from "../lib/zodToJsonSchema"; +import type { + UseWebMcpToolsProps, + UseWebMcpToolsResult, +} from "./useWebMcpTools.types"; + +export function useWebMcpTools( + props: UseWebMcpToolsProps, +): UseWebMcpToolsResult { + const {namespace, enabled = isWebMcpAvailable()} = props; + const [registered, setRegistered] = useState(false); + + 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 {data: appBuilderData} = useContext(AppBuilderDataContext); + + 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 appBuilderDataRef = useRef(appBuilderData); + appBuilderDataRef.current = appBuilderData; + + 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 getLiveParameters = (targetNamespace: string) => { + const paramStores = getParametersRef.current(targetNamespace); + + return Object.values(paramStores).map((store) => store.getState()); + }; + + const findParameterRef = ( + paramId: string, + paramName: string, + displayname?: string, + ) => { + const refs = appBuilderDataRef.current + ? getParameterRefs(appBuilderDataRef.current) + : []; + + return refs.find( + (ref) => + ref.name === paramId || + ref.name === paramName || + ref.name === displayname, + ); + }; + + const registerTools = async () => { + const modelContext = getModelContext(); + + try { + 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 ?? namespaceRef.current; + let parameters = getLiveParameters( + targetNamespace, + ).filter((p) => + SUPPORTED_PARAMETER_TYPES.includes( + p.definition.type, + ), + ); + + if (filter === "visible") { + const refs = appBuilderDataRef.current + ? getParameterRefs( + appBuilderDataRef.current, + ) + : []; + parameters = filterVisibleParameters( + parameters, + refs, + ); + } + + return { + parameters: parameters.map((param) => { + const def = param.definition; + const ref = findParameterRef( + def.id, + def.name, + def.displayname, + ); + + return mapParameterDefinition( + param, + ref, + ); + }), + }; + } catch (e) { + throw e instanceof Error + ? e + : new Error(String(e)); + } + }, + }, + {signal: controller.signal}, + ); + + 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); + const targetNamespace = namespaceRef.current; + const parameters = + getLiveParameters(targetNamespace); + + return await resolveAndUpdate( + targetNamespace, + parameters, + parsed.updates, + batchParameterValueUpdateRef.current, + ); + } catch (e) { + return { + applied: [], + errors: [ + { + name: "*", + message: + e instanceof Error + ? e.message + : String(e), + }, + ], + }; + } + }, + }, + {signal: controller.signal}, + ); + + 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 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: controller.signal}, + ); + + 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 = namespaceRef.current; + const beforeValues = new Map( + getParameterStates(targetNamespace).map( + (p) => [ + p.definition.id, + p.state.uiValue, + ], + ), + ); + const result = + await importModelStateRef.current(parsed); + + if (!result.success) { + return { + success: false as const, + message: result.message, + // TODO SS-9745: enrich with per-parameter invalid detail from hook. + invalidParameters: [], + }; + } + + const afterParams = + getParameterStates(targetNamespace); + const appliedParameterIds = afterParams + .filter( + (p) => + beforeValues.get( + p.definition.id, + ) !== p.state.uiValue, + ) + .map((p) => p.definition.id); + + if (appliedParameterIds.length === 0) { + return { + success: true as const, + appliedParameterIds: afterParams.map( + (p) => p.definition.id, + ), + }; + } + + return { + success: true as const, + appliedParameterIds, + }; + } catch (e) { + return { + success: false as const, + message: + e instanceof Error + ? e.message + : String(e), + invalidParameters: [], + }; + } + }, + }, + {signal: controller.signal}, + ); + + if (!cancelled) { + setRegistered(true); + } + } catch { + if (!cancelled) { + setRegistered(false); + } + } + }; + + void registerTools(); + + return () => { + cancelled = true; + controller.abort(); + setRegistered(false); + }; + }, [enabled, namespace, sessionReady, paramsPopulated]); + + if (enabled === false || !isWebMcpAvailable()) { + return {registered: false}; + } + + return {registered}; +} diff --git a/features/webmcp/model/useWebMcpTools.types.ts b/features/webmcp/model/useWebMcpTools.types.ts new file mode 100644 index 00000000..2507c316 --- /dev/null +++ b/features/webmcp/model/useWebMcpTools.types.ts @@ -0,0 +1,8 @@ +export interface UseWebMcpToolsProps { + namespace?: string; + enabled?: boolean; +} + +export interface UseWebMcpToolsResult { + registered: boolean; +} diff --git a/pages/appbuilder/AppBuilderPage.tsx b/pages/appbuilder/AppBuilderPage.tsx index c79fefed..fa45bf2a 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 From 6caf3ddb7bec5ca3af9be0a050a49c6df3b8e0c5 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Thu, 9 Jul 2026 16:30:38 +0300 Subject: [PATCH 02/20] SS-9745: Improve saved state error message --- .../lib/__tests__/parametersFilter.test.ts | 101 ++++++++++++++++++ entities/parameter/lib/parametersFilter.ts | 23 +++- .../model-state/config/importModelState.ts | 1 + .../model-state/model/useImportModelState.ts | 1 + features/webmcp/config/tools.ts | 2 +- features/webmcp/model/useWebMcpTools.ts | 4 +- 6 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 entities/parameter/lib/__tests__/parametersFilter.test.ts diff --git a/entities/parameter/lib/__tests__/parametersFilter.test.ts b/entities/parameter/lib/__tests__/parametersFilter.test.ts new file mode 100644 index 00000000..ac66e729 --- /dev/null +++ b/entities/parameter/lib/__tests__/parametersFilter.test.ts @@ -0,0 +1,101 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import {filterAndValidateParameters} 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.', + }, + ]); + }); +}); diff --git a/entities/parameter/lib/parametersFilter.ts b/entities/parameter/lib/parametersFilter.ts index cf8c33ca..04bbf413 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 "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, }; } diff --git a/features/model-state/config/importModelState.ts b/features/model-state/config/importModelState.ts index 0f6532a3..1e00a943 100644 --- a/features/model-state/config/importModelState.ts +++ b/features/model-state/config/importModelState.ts @@ -15,6 +15,7 @@ export type IImportModelStateResult = | { success: false; message: string; + invalidParameters?: Array<{name: string; message: string}>; } | { success: true; diff --git a/features/model-state/model/useImportModelState.ts b/features/model-state/model/useImportModelState.ts index 3d3a3711..601df72e 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, }; } diff --git a/features/webmcp/config/tools.ts b/features/webmcp/config/tools.ts index 54336ae2..bb150295 100644 --- a/features/webmcp/config/tools.ts +++ b/features/webmcp/config/tools.ts @@ -36,5 +36,5 @@ export const IMPORT_MODEL_STATE_TOOL_DESCRIPTION = "Waits for session update before returning. " + "Success: { success: true, appliedParameterIds: string[] } — parameter ids that changed during import (not a field named applied). " + "If no diff is detected, all parameter ids may be listed. " + - "Failure: { success: false, message: string }. " + + "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/model/useWebMcpTools.ts b/features/webmcp/model/useWebMcpTools.ts index f68000e8..e9f052ef 100644 --- a/features/webmcp/model/useWebMcpTools.ts +++ b/features/webmcp/model/useWebMcpTools.ts @@ -316,8 +316,8 @@ export function useWebMcpTools( return { success: false as const, message: result.message, - // TODO SS-9745: enrich with per-parameter invalid detail from hook. - invalidParameters: [], + invalidParameters: + result.invalidParameters ?? [], }; } From e205e2bc1a90f77ec68b110581faa56097bf67e3 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Thu, 9 Jul 2026 17:53:20 +0300 Subject: [PATCH 03/20] SS-9745: Review fixes --- .../lib/__tests__/parametersFilter.test.ts | 43 +++++- entities/parameter/lib/parametersFilter.ts | 7 +- .../lib/__tests__/getParameterRefs.test.ts | 134 ++++++++++++++++++ features/appbuilder/lib/appbuilder.ts | 101 ++++++++++--- .../model-state/config/importModelState.ts | 1 + .../model-state/model/useImportModelState.ts | 6 + .../computeAppliedParameterIds.test.ts | 43 ++++++ .../__tests__/filterVisibleParameters.test.ts | 4 +- .../parameterDefinitionMapper.test.ts | 22 +++ .../setParameterValues.feedback.test.ts | 106 +++++++++++++- .../__tests__/webmcpAvailability.test.ts | 80 +++++++++++ .../webmcp/__tests__/webmcpSchemas.test.ts | 105 +++++++++++++- features/webmcp/config/importModelState.ts | 3 + .../webmcp/config/listParameterDefinitions.ts | 10 ++ features/webmcp/config/setParameterValues.ts | 17 ++- features/webmcp/config/tools.ts | 8 +- .../webmcp/evals/__fixtures__/parameters.ts | 10 ++ features/webmcp/evals/evals.json | 2 +- features/webmcp/evals/runWebmcpEvals.ts | 53 ++++--- .../webmcp/lib/computeAppliedParameterIds.ts | 16 +++ .../webmcp/lib/filterVisibleParameters.ts | 6 +- features/webmcp/lib/formatToolInputError.ts | 12 ++ .../webmcp/lib/parameterDefinitionMapper.ts | 6 +- features/webmcp/lib/webmcpAvailability.ts | 19 +++ features/webmcp/model/useWebMcpTools.ts | 90 ++++++------ features/webmcp/model/useWebMcpTools.types.ts | 9 ++ 26 files changed, 804 insertions(+), 109 deletions(-) create mode 100644 features/appbuilder/lib/__tests__/getParameterRefs.test.ts create mode 100644 features/webmcp/__tests__/computeAppliedParameterIds.test.ts create mode 100644 features/webmcp/__tests__/webmcpAvailability.test.ts create mode 100644 features/webmcp/lib/computeAppliedParameterIds.ts create mode 100644 features/webmcp/lib/formatToolInputError.ts diff --git a/entities/parameter/lib/__tests__/parametersFilter.test.ts b/entities/parameter/lib/__tests__/parametersFilter.test.ts index ac66e729..91396cf7 100644 --- a/entities/parameter/lib/__tests__/parametersFilter.test.ts +++ b/entities/parameter/lib/__tests__/parametersFilter.test.ts @@ -1,7 +1,10 @@ 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} from "../parametersFilter"; - +import { + filterAndValidateParameters, + generateParameterFeedback, +} from "../parametersFilter"; function createMockParameter( overrides: Partial> & { definition: IShapeDiverParameter["definition"]; @@ -99,3 +102,39 @@ describe("filterAndValidateParameters", () => { ]); }); }); + +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 04bbf413..fc7abd75 100644 --- a/entities/parameter/lib/parametersFilter.ts +++ b/entities/parameter/lib/parametersFilter.ts @@ -144,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/lib/__tests__/getParameterRefs.test.ts b/features/appbuilder/lib/__tests__/getParameterRefs.test.ts new file mode 100644 index 00000000..303db6d5 --- /dev/null +++ b/features/appbuilder/lib/__tests__/getParameterRefs.test.ts @@ -0,0 +1,134 @@ +import { + AppBuilderContainerNameType, + IAppBuilder, +} from "../../config/appbuilder"; +import {getParameterRefs} from "../appbuilder"; + +function minimalAppBuilder( + containers: IAppBuilder["containers"], +): IAppBuilder { + return { + version: "1.0", + containers, + }; +} + +describe("getParameterRefs", () => { + it("collects refs from accordion widgets in containers and tabs", () => { + const data = minimalAppBuilder([ + { + name: AppBuilderContainerNameType.Left, + widgets: [ + { + type: "accordion", + props: {parameters: [{name: "width"}]}, + }, + ], + tabs: [ + { + name: "Tab 1", + widgets: [ + { + type: "accordion", + props: {parameters: [{name: "height"}]}, + }, + ], + }, + ], + }, + ]); + + expect(getParameterRefs(data).map((ref) => ref.name)).toEqual([ + "width", + "height", + ]); + }); + + it("collects refs from form parameters and parameter controls", () => { + const data = minimalAppBuilder([ + { + name: AppBuilderContainerNameType.Left, + widgets: [ + { + type: "form", + props: { + parameters: [{name: "form-param"}], + controls: [ + {type: "parameter", props: {name: "control-param"}}, + {type: "export", props: {name: "some-export"}}, + ], + }, + }, + ], + }, + ]); + + expect(getParameterRefs(data).map((ref) => ref.name)).toEqual([ + "form-param", + "control-param", + ]); + }); + + it("collects refs from controls widgets", () => { + const data = minimalAppBuilder([ + { + name: AppBuilderContainerNameType.Right, + widgets: [ + { + type: "controls", + props: { + controls: [ + { + type: "parameter", + props: { + name: "slider", + sessionId: "session-a", + }, + }, + ], + }, + }, + ], + }, + ]); + + expect(getParameterRefs(data)).toEqual([ + {name: "slider", sessionId: "session-a"}, + ]); + }); + + it("deduplicates refs by name and sessionId", () => { + const data = minimalAppBuilder([ + { + name: AppBuilderContainerNameType.Left, + widgets: [ + { + type: "accordion", + props: {parameters: [{name: "width"}]}, + }, + { + type: "form", + props: { + parameters: [{name: "width"}], + controls: [ + {type: "parameter", props: {name: "width"}}, + { + type: "parameter", + props: { + name: "width", + sessionId: "other-session", + }, + }, + ], + }, + }, + ], + }, + ]); + + expect(getParameterRefs(data)).toEqual([ + {name: "width"}, + {name: "width", sessionId: "other-session"}, + ]); + }); +}); diff --git a/features/appbuilder/lib/appbuilder.ts b/features/appbuilder/lib/appbuilder.ts index 9855dc26..015e4d11 100644 --- a/features/appbuilder/lib/appbuilder.ts +++ b/features/appbuilder/lib/appbuilder.ts @@ -1,33 +1,98 @@ import { IAppBuilder, + IAppBuilderControl, IAppBuilderParameterRef, + IAppBuilderWidget, isAccordionWidget, + isControlsWidget, + isFormWidget, + isParameterRefControl, } from "../config/appbuilder"; +function refKey( + ref: Pick, +): string { + return `${ref.sessionId ?? ""}\0${ref.name}`; +} + +function dedupeParameterRefs( + refs: IAppBuilderParameterRef[], +): IAppBuilderParameterRef[] { + const seen = new Set(); + const result: IAppBuilderParameterRef[] = []; + + for (const ref of refs) { + const key = refKey(ref); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(ref); + } + + return result; +} + +function parameterRefsFromControls( + controls: IAppBuilderControl[] | undefined, +): IAppBuilderParameterRef[] { + if (!controls) { + return []; + } + + return controls.filter(isParameterRefControl).map((control) => ({ + name: control.props.name, + sessionId: control.props.sessionId, + overrides: control.props.overrides, + disableIfDirty: control.props.disableIfDirty, + acceptRejectMode: control.props.acceptRejectMode, + })); +} + +function parameterRefsFromWidget( + widget: IAppBuilderWidget, +): IAppBuilderParameterRef[] { + const refs: IAppBuilderParameterRef[] = []; + + if (isAccordionWidget(widget) && widget.props.parameters) { + refs.push(...widget.props.parameters); + } + + if (isFormWidget(widget)) { + if (widget.props.parameters) { + refs.push(...widget.props.parameters); + } + refs.push(...parameterRefsFromControls(widget.props.controls)); + } + + if (isControlsWidget(widget)) { + refs.push(...parameterRefsFromControls(widget.props.controls)); + } + + return refs; +} + /** * Given an App Builder data object, return all parameter references - * occuring in any of the accordion widgets. - * @param data - * @returns + * placed in accordion, form, or controls widgets (including tab layouts). */ export function getParameterRefs(data: IAppBuilder): IAppBuilderParameterRef[] { - return data.containers.reduce((acc, container) => { + const refs: IAppBuilderParameterRef[] = []; + + for (const container of data.containers) { if (container.widgets) { - container.widgets.forEach((widget) => { - if (isAccordionWidget(widget) && widget.props.parameters) { - acc.push(...widget.props.parameters); - } - }); + for (const widget of container.widgets) { + refs.push(...parameterRefsFromWidget(widget)); + } } if (container.tabs) { - container.tabs.forEach((tab) => { - tab.widgets.forEach((widget) => { - if (isAccordionWidget(widget) && widget.props.parameters) { - acc.push(...widget.props.parameters); - } - }); - }); + for (const tab of container.tabs) { + for (const widget of tab.widgets) { + refs.push(...parameterRefsFromWidget(widget)); + } + } } - return acc; - }, [] as IAppBuilderParameterRef[]); + } + + return dedupeParameterRefs(refs); } diff --git a/features/model-state/config/importModelState.ts b/features/model-state/config/importModelState.ts index 1e00a943..1144a2be 100644 --- a/features/model-state/config/importModelState.ts +++ b/features/model-state/config/importModelState.ts @@ -20,4 +20,5 @@ export type IImportModelStateResult = | { success: true; data: ResGetModelState; + invalidParameters?: Array<{name: string; message: string}>; }; diff --git a/features/model-state/model/useImportModelState.ts b/features/model-state/model/useImportModelState.ts index 601df72e..7033bef9 100644 --- a/features/model-state/model/useImportModelState.ts +++ b/features/model-state/model/useImportModelState.ts @@ -136,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__/filterVisibleParameters.test.ts b/features/webmcp/__tests__/filterVisibleParameters.test.ts index 683a2bad..1c0cf32a 100644 --- a/features/webmcp/__tests__/filterVisibleParameters.test.ts +++ b/features/webmcp/__tests__/filterVisibleParameters.test.ts @@ -13,10 +13,10 @@ function mockParam(id: string, name: string): IShapeDiverParameter { } describe("filterVisibleParameters", () => { - it("returns all parameters when accordion refs are empty", () => { + it("returns empty array when layout refs are empty", () => { const params = [mockParam("a", "Slider1"), mockParam("b", "Slider2")]; - expect(filterVisibleParameters(params, [])).toHaveLength(2); + expect(filterVisibleParameters(params, [])).toEqual([]); }); it("filters by ref name, id, or displayname", () => { diff --git a/features/webmcp/__tests__/parameterDefinitionMapper.test.ts b/features/webmcp/__tests__/parameterDefinitionMapper.test.ts index 1adf54fe..f74b9511 100644 --- a/features/webmcp/__tests__/parameterDefinitionMapper.test.ts +++ b/features/webmcp/__tests__/parameterDefinitionMapper.test.ts @@ -46,6 +46,7 @@ describe("mapParameterDefinition", () => { id: "list-1", name: "Material", type: ResParameterType.STRINGLIST, + settable: true, choices: ["Wood", "Metal"], currentValue: 0, defaultValue: 1, @@ -69,6 +70,7 @@ describe("mapParameterDefinition", () => { 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}, }); @@ -92,6 +94,7 @@ describe("mapParameterDefinition", () => { id: "float-1", name: "Width", type: ResParameterType.FLOAT, + settable: true, min: 0, max: 100, decimalplaces: 2, @@ -115,6 +118,7 @@ describe("mapParameterDefinition", () => { id: "bool-1", name: "Enabled", type: ResParameterType.BOOL, + settable: true, currentValue: true, defaultValue: false, }); @@ -150,6 +154,7 @@ describe("mapParameterDefinition", () => { name: "Width", displayname: "Custom Width", type: ResParameterType.INT, + settable: true, group: "Sizing", tooltip: "Override tooltip", min: 0, @@ -158,4 +163,21 @@ describe("mapParameterDefinition", () => { 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 index 18e9abab..2cbde27e 100644 --- a/features/webmcp/__tests__/setParameterValues.feedback.test.ts +++ b/features/webmcp/__tests__/setParameterValues.feedback.test.ts @@ -36,6 +36,11 @@ describe("resolveAndUpdate", () => { .fn() .mockResolvedValue(undefined); + const getParametersFor = + (paramsByNamespace: Record[]>) => + (ns: string) => + paramsByNamespace[ns] ?? []; + beforeEach(() => { batchParameterValueUpdate.mockClear(); }); @@ -43,7 +48,7 @@ describe("resolveAndUpdate", () => { it("returns error for unknown parameter", async () => { const result = await resolveAndUpdate( namespace, - [], + getParametersFor({[namespace]: []}), [{name: "Missing", value: 1}], batchParameterValueUpdate, ); @@ -76,7 +81,7 @@ describe("resolveAndUpdate", () => { const result = await resolveAndUpdate( namespace, - [param], + getParametersFor({[namespace]: [param]}), [{name: "Width", value: 42}], batchParameterValueUpdate, ); @@ -105,7 +110,7 @@ describe("resolveAndUpdate", () => { const result = await resolveAndUpdate( namespace, - [param], + getParametersFor({[namespace]: [param]}), [{name: "Width", value: 200}], batchParameterValueUpdate, ); @@ -137,7 +142,7 @@ describe("resolveAndUpdate", () => { const result = await resolveAndUpdate( namespace, - [param], + getParametersFor({[namespace]: [param]}), [ {name: "Width", value: 20}, {name: "width", value: 30}, @@ -180,7 +185,7 @@ describe("resolveAndUpdate", () => { const result = await resolveAndUpdate( namespace, - [param], + getParametersFor({[namespace]: [param]}), [{name: "Paint", value: colorValue}], batchParameterValueUpdate, ); @@ -209,7 +214,7 @@ describe("resolveAndUpdate", () => { const result = await resolveAndUpdate( namespace, - [param], + getParametersFor({[namespace]: [param]}), [ { name: "Width", @@ -247,7 +252,7 @@ describe("resolveAndUpdate", () => { const result = await resolveAndUpdate( namespace, - [param], + getParametersFor({[namespace]: [param]}), [{name: "Material", value: 1}], batchParameterValueUpdate, ); @@ -258,4 +263,91 @@ describe("resolveAndUpdate", () => { [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 index 779b4a4e..c5219160 100644 --- a/features/webmcp/__tests__/webmcpSchemas.test.ts +++ b/features/webmcp/__tests__/webmcpSchemas.test.ts @@ -1,7 +1,14 @@ import {createModelStateInputSchema} from "../config/createModelState"; -import {importModelStateInputSchema} from "../config/importModelState"; -import {listParameterDefinitionsInputSchema} from "../config/listParameterDefinitions"; +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", () => { @@ -34,6 +41,58 @@ describe("webmcp input schemas", () => { }); }); + 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( @@ -95,4 +154,44 @@ describe("webmcp input schemas", () => { 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/config/importModelState.ts b/features/webmcp/config/importModelState.ts index fa04176f..e4229b3f 100644 --- a/features/webmcp/config/importModelState.ts +++ b/features/webmcp/config/importModelState.ts @@ -16,6 +16,9 @@ export const importModelStateInvalidParameterSchema = z.object({ export const importModelStateSuccessOutputSchema = z.object({ success: z.literal(true), appliedParameterIds: z.array(z.string()), + invalidParameters: z + .array(importModelStateInvalidParameterSchema) + .optional(), }); export const importModelStateFailureOutputSchema = z.object({ diff --git a/features/webmcp/config/listParameterDefinitions.ts b/features/webmcp/config/listParameterDefinitions.ts index 98a6e203..7203d427 100644 --- a/features/webmcp/config/listParameterDefinitions.ts +++ b/features/webmcp/config/listParameterDefinitions.ts @@ -29,6 +29,7 @@ export const ListParameterDefinitionItemSchema = z.object({ currentValue: parameterValueSchema.optional(), defaultValue: parameterValueSchema.optional(), hidden: z.boolean().optional(), + settable: z.boolean(), }); export const listParameterDefinitionsInputSchema = z.strictObject({ @@ -44,8 +45,14 @@ export const listParameterDefinitionsInputSchema = z.strictObject({ .describe("Optional session namespace. Omit for the main model."), }); +export const listParameterDefinitionsErrorSchema = z.object({ + name: z.string(), + message: z.string(), +}); + export const listParameterDefinitionsOutputSchema = z.object({ parameters: z.array(ListParameterDefinitionItemSchema), + errors: z.array(listParameterDefinitionsErrorSchema).optional(), }); export type ListParameterDefinitionItem = z.infer< @@ -57,6 +64,9 @@ export type ListParameterDefinitionsInput = z.infer< export type ListParameterDefinitionsOutput = z.infer< typeof listParameterDefinitionsOutputSchema >; +export type ListParameterDefinitionsError = z.infer< + typeof listParameterDefinitionsErrorSchema +>; /** Supported types of parameters (for now). */ // TODO SS-9745: Grasshopper-dev-controlled exposure for additional parameter types. diff --git a/features/webmcp/config/setParameterValues.ts b/features/webmcp/config/setParameterValues.ts index 57913721..ca63efb7 100644 --- a/features/webmcp/config/setParameterValues.ts +++ b/features/webmcp/config/setParameterValues.ts @@ -10,7 +10,7 @@ import { parseStringListIndex, toStringListStoreValue, } from "../lib/stringListValue"; -import {parameterValueSchema} from "./listParameterDefinitions"; +import {parameterValueSchema, SUPPORTED_PARAMETER_TYPES} from "./listParameterDefinitions"; export const setParameterValuesInputSchema = z.strictObject({ updates: z @@ -147,8 +147,8 @@ function getValidationErrorMessage( * Pure validation and batch-update logic for set_parameter_values. */ export async function resolveAndUpdate( - namespace: string, - parameters: IShapeDiverParameter[], + defaultNamespace: string, + getParameters: (namespace: string) => IShapeDiverParameter[], updates: ParameterUpdateInput[], batchUpdate: IShapeDiverStoreParameters["batchParameterValueUpdate"], ): Promise { @@ -157,6 +157,8 @@ export async function resolveAndUpdate( const processedIds = new Set(); for (const update of updates) { + const targetNamespace = update.sessionId ?? defaultNamespace; + const parameters = getParameters(targetNamespace); const parameter = findParameter(parameters, update.name); if (!parameter) { errors.push({ @@ -179,6 +181,14 @@ export async function resolveAndUpdate( const def = parameter.definition; const {value} = update; + if (!SUPPORTED_PARAMETER_TYPES.includes(def.type)) { + errors.push({ + name: update.name, + message: `Parameter type "${def.type}" is not supported for setting via WebMCP.`, + }); + continue; + } + if (isColorObject(value) && def.type !== ResParameterType.COLOR) { errors.push({ name: update.name, @@ -241,7 +251,6 @@ export async function resolveAndUpdate( preparedValue = value; } - const targetNamespace = update.sessionId ?? namespace; if (!valuesByNamespace[targetNamespace]) { valuesByNamespace[targetNamespace] = {}; } diff --git a/features/webmcp/config/tools.ts b/features/webmcp/config/tools.ts index bb150295..c7f1d62b 100644 --- a/features/webmcp/config/tools.ts +++ b/features/webmcp/config/tools.ts @@ -10,7 +10,9 @@ 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: [...] } with id, name, type, choices/min/max, currentValue. " + + "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."; @@ -34,7 +36,7 @@ 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[] } — parameter ids that changed during import (not a field named applied). " + - "If no diff is detected, all parameter ids may be listed. " + + "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 index f4cec63e..d4ffda27 100644 --- a/features/webmcp/evals/__fixtures__/parameters.ts +++ b/features/webmcp/evals/__fixtures__/parameters.ts @@ -163,6 +163,15 @@ export const colorNamedStringListParameter = createMockParameter({ state: {uiValue: 7} as IShapeDiverParameter["state"], }); +export const fileParameter = createMockParameter({ + definition: { + id: "upload-file", + name: "Upload", + type: ResParameterType.FILE, + } as unknown as IShapeDiverParameter["definition"], + state: {uiValue: ""} as IShapeDiverParameter["state"], +}); + export const allParameters: IShapeDiverParameter[] = [ intParameter, stringListParameter, @@ -170,6 +179,7 @@ export const allParameters: IShapeDiverParameter[] = [ floatParameter, boolParameter, colorNamedStringListParameter, + fileParameter, ]; /** Refs for the "visible" list filter — width + paint only. */ diff --git a/features/webmcp/evals/evals.json b/features/webmcp/evals/evals.json index 269608d2..84cc405b 100644 --- a/features/webmcp/evals/evals.json +++ b/features/webmcp/evals/evals.json @@ -4,7 +4,7 @@ "tool": "list_parameter_definitions", "description": "List all supported parameters", "input": {"filter": "all"}, - "expect": {"parametersCount": 6} + "expect": {"parametersCount": 7} }, { "id": "list_visible", diff --git a/features/webmcp/evals/runWebmcpEvals.ts b/features/webmcp/evals/runWebmcpEvals.ts index 1e5e3d9a..4a33b3b9 100644 --- a/features/webmcp/evals/runWebmcpEvals.ts +++ b/features/webmcp/evals/runWebmcpEvals.ts @@ -7,13 +7,14 @@ import {createModelStateInputSchema} from "../config/createModelState"; import {importModelStateInputSchema} from "../config/importModelState"; import { listParameterDefinitionsInputSchema, - SUPPORTED_PARAMETER_TYPES, + listParameterDefinitionsOutputSchema, } from "../config/listParameterDefinitions"; import { resolveAndUpdate, setParameterValuesInputSchema, } from "../config/setParameterValues"; import {filterVisibleParameters} from "../lib/filterVisibleParameters"; +import {formatToolInputError} from "../lib/formatToolInputError"; import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; import { allParameters, @@ -63,19 +64,26 @@ function findParameterRef(param: (typeof allParameters)[number]) { } function runListScenario(input: Record) { - const parsed = listParameterDefinitionsInputSchema.parse(input); - const filter = parsed.filter ?? "all"; - let parameters = allParameters.filter((p) => - SUPPORTED_PARAMETER_TYPES.includes(p.definition.type), - ); + try { + const parsed = listParameterDefinitionsInputSchema.parse(input); + const filter = parsed.filter ?? "all"; + let parameters = allParameters; - if (filter === "visible") { - parameters = filterVisibleParameters(parameters, parameterRefs); - } + if (filter === "visible") { + parameters = filterVisibleParameters(parameters, parameterRefs); + } - return parameters.map((param) => - mapParameterDefinition(param, findParameterRef(param)), - ); + return { + parameters: parameters.map((param) => + mapParameterDefinition(param, findParameterRef(param)), + ), + }; + } catch (e) { + return { + parameters: [], + ...formatToolInputError(e), + }; + } } function assertInputSchemaReject( @@ -156,13 +164,22 @@ function assertSetErrorExpectations( function assertListScenario(scenario: EvalScenario): string | null { if (scenario.expect.inputSchemaReject) { - return assertInputSchemaReject( - listParameterDefinitionsInputSchema, - scenario.input, - ); + 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 parameters = runListScenario(scenario.input); + const result = runListScenario(scenario.input); + const parameters = result.parameters; const {expect} = scenario; if ( @@ -198,7 +215,7 @@ async function assertSetScenario( const result = await resolveAndUpdate( EVAL_NAMESPACE, - allParameters, + (ns) => (ns === EVAL_NAMESPACE ? allParameters : []), parsed.updates, async () => undefined, ); 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/filterVisibleParameters.ts b/features/webmcp/lib/filterVisibleParameters.ts index eb22b849..42d095fc 100644 --- a/features/webmcp/lib/filterVisibleParameters.ts +++ b/features/webmcp/lib/filterVisibleParameters.ts @@ -16,15 +16,15 @@ function parameterMatchesRef( } /** - * Parameters shown in accordion widgets. When layout has no accordion refs, - * returns all parameters (no custom UI layout to filter against). + * Parameters placed in the configurator UI (accordion, form, controls widgets). + * When layout has no parameter refs, returns empty — visible means UI-placed only. */ export function filterVisibleParameters( parameters: IShapeDiverParameter[], refs: IAppBuilderParameterRef[], ): IShapeDiverParameter[] { if (refs.length === 0) { - return parameters; + return []; } return parameters.filter((param) => parameterMatchesRef(param, refs)); 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 index 099820f7..0bbcd5fe 100644 --- a/features/webmcp/lib/parameterDefinitionMapper.ts +++ b/features/webmcp/lib/parameterDefinitionMapper.ts @@ -2,7 +2,10 @@ import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/par import {IAppBuilderParameterRef} from "@AppBuilderLib/features/appbuilder/config/appbuilder"; import {decomposeSdColor} from "@AppBuilderLib/shared/lib/colors"; import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; -import type {ListParameterDefinitionItem} from "../config/listParameterDefinitions"; +import { + SUPPORTED_PARAMETER_TYPES, + type ListParameterDefinitionItem, +} from "../config/listParameterDefinitions"; import {parseStringListIndex} from "./stringListValue"; export type {ListParameterDefinitionItem}; @@ -19,6 +22,7 @@ export function mapParameterDefinition( id: def.id, name, type: def.type, + settable: SUPPORTED_PARAMETER_TYPES.includes(def.type), }; if (def.hidden !== undefined) { diff --git a/features/webmcp/lib/webmcpAvailability.ts b/features/webmcp/lib/webmcpAvailability.ts index caa95e0e..6c7b4132 100644 --- a/features/webmcp/lib/webmcpAvailability.ts +++ b/features/webmcp/lib/webmcpAvailability.ts @@ -43,6 +43,25 @@ 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) { diff --git a/features/webmcp/model/useWebMcpTools.ts b/features/webmcp/model/useWebMcpTools.ts index e9f052ef..cac572d7 100644 --- a/features/webmcp/model/useWebMcpTools.ts +++ b/features/webmcp/model/useWebMcpTools.ts @@ -9,10 +9,7 @@ import {useContext, useEffect, useRef, useState} from "react"; import {useShallow} from "zustand/react/shallow"; import {createModelStateInputSchema} from "../config/createModelState"; import {importModelStateInputSchema} from "../config/importModelState"; -import { - listParameterDefinitionsInputSchema, - SUPPORTED_PARAMETER_TYPES, -} from "../config/listParameterDefinitions"; +import {listParameterDefinitionsInputSchema} from "../config/listParameterDefinitions"; import { resolveAndUpdate, setParameterValuesInputSchema, @@ -27,9 +24,15 @@ import { SET_PARAMETER_VALUES_TOOL_DESCRIPTION, SET_PARAMETER_VALUES_TOOL_NAME, } from "../config/tools"; +import {computeAppliedParameterIds} from "../lib/computeAppliedParameterIds"; import {filterVisibleParameters} from "../lib/filterVisibleParameters"; +import {formatToolInputError} from "../lib/formatToolInputError"; import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; -import {getModelContext, isWebMcpAvailable} from "../lib/webmcpAvailability"; +import { + getModelContext, + getWebMcpEnvironment, + isWebMcpAvailable, +} from "../lib/webmcpAvailability"; import {zodToJsonSchema} from "../lib/zodToJsonSchema"; import type { UseWebMcpToolsProps, @@ -41,6 +44,8 @@ export function useWebMcpTools( ): UseWebMcpToolsResult { const {namespace, enabled = isWebMcpAvailable()} = props; const [registered, setRegistered] = useState(false); + const environment = getWebMcpEnvironment(); + const ready = registered && environment.ready; const {sessions} = useShapeDiverStoreSession( useShallow((state) => ({ @@ -148,13 +153,8 @@ export function useWebMcpTools( const filter = parsed.filter ?? "all"; const targetNamespace = parsed.sessionId ?? namespaceRef.current; - let parameters = getLiveParameters( - targetNamespace, - ).filter((p) => - SUPPORTED_PARAMETER_TYPES.includes( - p.definition.type, - ), - ); + let parameters = + getLiveParameters(targetNamespace); if (filter === "visible") { const refs = appBuilderDataRef.current @@ -184,9 +184,10 @@ export function useWebMcpTools( }), }; } catch (e) { - throw e instanceof Error - ? e - : new Error(String(e)); + return { + parameters: [], + ...formatToolInputError(e), + }; } }, }, @@ -209,27 +210,17 @@ export function useWebMcpTools( const parsed = setParameterValuesInputSchema.parse(input); const targetNamespace = namespaceRef.current; - const parameters = - getLiveParameters(targetNamespace); return await resolveAndUpdate( targetNamespace, - parameters, + getLiveParameters, parsed.updates, batchParameterValueUpdateRef.current, ); } catch (e) { return { applied: [], - errors: [ - { - name: "*", - message: - e instanceof Error - ? e.message - : String(e), - }, - ], + ...formatToolInputError(e), }; } }, @@ -323,27 +314,21 @@ export function useWebMcpTools( const afterParams = getParameterStates(targetNamespace); - const appliedParameterIds = afterParams - .filter( - (p) => - beforeValues.get( - p.definition.id, - ) !== p.state.uiValue, - ) - .map((p) => p.definition.id); - - if (appliedParameterIds.length === 0) { - return { - success: true as const, - appliedParameterIds: afterParams.map( - (p) => p.definition.id, - ), - }; - } + const appliedParameterIds = + computeAppliedParameterIds( + beforeValues, + afterParams, + ); return { success: true as const, appliedParameterIds, + ...(result.invalidParameters + ? { + invalidParameters: + result.invalidParameters, + } + : {}), }; } catch (e) { return { @@ -379,9 +364,22 @@ export function useWebMcpTools( }; }, [enabled, namespace, sessionReady, paramsPopulated]); + const environmentSnapshot = { + modelContextAvailable: environment.modelContextAvailable, + crossOriginIsolated: environment.crossOriginIsolated, + }; + if (enabled === false || !isWebMcpAvailable()) { - return {registered: false}; + return { + registered: false, + ready: false, + environment: environmentSnapshot, + }; } - return {registered}; + return { + registered, + ready, + environment: environmentSnapshot, + }; } diff --git a/features/webmcp/model/useWebMcpTools.types.ts b/features/webmcp/model/useWebMcpTools.types.ts index 2507c316..42705cb8 100644 --- a/features/webmcp/model/useWebMcpTools.types.ts +++ b/features/webmcp/model/useWebMcpTools.types.ts @@ -3,6 +3,15 @@ export interface UseWebMcpToolsProps { 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; } From dd7c23941166fff7c3d6f344ffb286ee4206cb66 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 10 Jul 2026 10:17:31 +0300 Subject: [PATCH 04/20] SS-9745: Add descriptions --- features/appbuilder/lib/appbuilder.ts | 33 +++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/features/appbuilder/lib/appbuilder.ts b/features/appbuilder/lib/appbuilder.ts index 015e4d11..a6730ff0 100644 --- a/features/appbuilder/lib/appbuilder.ts +++ b/features/appbuilder/lib/appbuilder.ts @@ -9,12 +9,20 @@ import { isParameterRefControl, } from "../config/appbuilder"; +/** + * Stable deduplication key for a parameter reference. + * Combines optional `sessionId` and `name` so the same parameter in different sessions stays distinct. + */ function refKey( ref: Pick, ): string { return `${ref.sessionId ?? ""}\0${ref.name}`; } +/** + * Removes duplicate parameter references while preserving first-seen order. + * Two refs are equal when both `name` and `sessionId` match. + */ function dedupeParameterRefs( refs: IAppBuilderParameterRef[], ): IAppBuilderParameterRef[] { @@ -33,6 +41,13 @@ function dedupeParameterRefs( return result; } +/** + * Maps parameter-type controls to {@link IAppBuilderParameterRef} entries. + * Export, action, and output controls are ignored. + * + * @param controls - Control list from a form or controls widget. + * @returns Parameter refs derived from `type: "parameter"` controls. + */ function parameterRefsFromControls( controls: IAppBuilderControl[] | undefined, ): IAppBuilderParameterRef[] { @@ -49,6 +64,14 @@ function parameterRefsFromControls( })); } +/** + * Collects parameter references declared by a single widget. + * + * Supported widget types: + * - `accordion` — `props.parameters` + * - `form` — `props.parameters` plus parameter controls in `props.controls` + * - `controls` — parameter controls in `props.controls` + */ function parameterRefsFromWidget( widget: IAppBuilderWidget, ): IAppBuilderParameterRef[] { @@ -73,8 +96,14 @@ function parameterRefsFromWidget( } /** - * Given an App Builder data object, return all parameter references - * placed in accordion, form, or controls widgets (including tab layouts). + * Returns all parameter references placed in the App Builder UI layout. + * + * Walks every container and tab, collecting refs from accordion, form, and + * controls widgets. Used to determine which session parameters are shown in + * the configurator (e.g. WebMCP `filter: "visible"`, in-app agent context). + * + * @param data - Parsed App Builder settings / layout tree. + * @returns Deduplicated refs ordered by first appearance in the layout. */ export function getParameterRefs(data: IAppBuilder): IAppBuilderParameterRef[] { const refs: IAppBuilderParameterRef[] = []; From d598db51f638190fe581f6cc02c0360af101244a Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 10 Jul 2026 10:46:20 +0300 Subject: [PATCH 05/20] SS-9745: Add widget type guard protection --- features/appbuilder/config/appbuilder.ts | 5 +- .../lib/__tests__/getParameterRefs.test.ts | 63 +++++++++++++ features/appbuilder/lib/appbuilder.ts | 88 ++++++++++++++----- 3 files changed, 133 insertions(+), 23 deletions(-) diff --git a/features/appbuilder/config/appbuilder.ts b/features/appbuilder/config/appbuilder.ts index 14fa08cd..25a96dae 100644 --- a/features/appbuilder/config/appbuilder.ts +++ b/features/appbuilder/config/appbuilder.ts @@ -1041,7 +1041,10 @@ export interface IAppBuilderWidgetPropsStackUi { * * * add the identifier for the new type to AppBuilderWidgetType, and * * define a new interface for the properties of the widget type and - * add it to the union type of "props". + * add it to the union type of "props", and + * * add a matching `case` in + * {@link getParameterRefs @AppBuilderLib/features/appbuilder/lib/appbuilder} + * (exhaustive `switch` — TypeScript errors if a type is missing). */ export interface IAppBuilderWidget { /** Type of the widget. */ diff --git a/features/appbuilder/lib/__tests__/getParameterRefs.test.ts b/features/appbuilder/lib/__tests__/getParameterRefs.test.ts index 303db6d5..fa91813e 100644 --- a/features/appbuilder/lib/__tests__/getParameterRefs.test.ts +++ b/features/appbuilder/lib/__tests__/getParameterRefs.test.ts @@ -131,4 +131,67 @@ describe("getParameterRefs", () => { {name: "width", sessionId: "other-session"}, ]); }); + + it("collects refs from widgets nested in stackUi", () => { + const data = minimalAppBuilder([ + { + name: AppBuilderContainerNameType.Left, + widgets: [ + { + type: "stackUi", + props: { + name: "Options", + widgets: [ + { + type: "controls", + props: { + controls: [ + { + type: "parameter", + props: {name: "nested-slider"}, + }, + ], + }, + }, + ], + }, + }, + ], + }, + ]); + + expect(getParameterRefs(data).map((ref) => ref.name)).toEqual([ + "nested-slider", + ]); + }); + + it("collects refs from widgets nested in accordionUi", () => { + const data = minimalAppBuilder([ + { + name: AppBuilderContainerNameType.Left, + widgets: [ + { + type: "accordionUi", + props: { + items: [ + { + name: "Section", + widgets: [ + { + type: "accordion", + props: { + parameters: [{name: "depth"}], + }, + }, + ], + }, + ], + }, + }, + ], + }, + ]); + + expect(getParameterRefs(data).map((ref) => ref.name)).toEqual(["depth"]); + }); }); diff --git a/features/appbuilder/lib/appbuilder.ts b/features/appbuilder/lib/appbuilder.ts index a6730ff0..31f50a23 100644 --- a/features/appbuilder/lib/appbuilder.ts +++ b/features/appbuilder/lib/appbuilder.ts @@ -3,12 +3,23 @@ import { IAppBuilderControl, IAppBuilderParameterRef, IAppBuilderWidget, + isAccordionUiWidget, isAccordionWidget, isControlsWidget, isFormWidget, isParameterRefControl, + isStackUiWidget, } from "../config/appbuilder"; +/** + * Compile-time exhaustiveness guard for {@link parameterRefsFromWidget}. + * If `AppBuilderWidgetType` gains a new member without a matching `switch` case, + * TypeScript reports an error on the `never` assignment in the default branch. + */ +function assertNever(value: never): never { + throw new Error(`Unhandled widget type: ${String(value)}`); +} + /** * Stable deduplication key for a parameter reference. * Combines optional `sessionId` and `name` so the same parameter in different sessions stays distinct. @@ -67,39 +78,72 @@ function parameterRefsFromControls( /** * Collects parameter references declared by a single widget. * - * Supported widget types: - * - `accordion` — `props.parameters` - * - `form` — `props.parameters` plus parameter controls in `props.controls` - * - `controls` — parameter controls in `props.controls` + * Uses an exhaustive `switch` on widget `type` so new widget types + * must be handled here (or explicitly grouped as non-parameter-bearing). + * + * Container widgets (`accordionUi`, `stackUi`) recurse into nested children. */ function parameterRefsFromWidget( widget: IAppBuilderWidget, ): IAppBuilderParameterRef[] { - const refs: IAppBuilderParameterRef[] = []; - - if (isAccordionWidget(widget) && widget.props.parameters) { - refs.push(...widget.props.parameters); - } - - if (isFormWidget(widget)) { - if (widget.props.parameters) { - refs.push(...widget.props.parameters); + switch (widget.type) { + case "accordion": + return isAccordionWidget(widget) && widget.props.parameters + ? [...widget.props.parameters] + : []; + case "form": { + if (!isFormWidget(widget)) { + return []; + } + const refs: IAppBuilderParameterRef[] = []; + if (widget.props.parameters) { + refs.push(...widget.props.parameters); + } + refs.push(...parameterRefsFromControls(widget.props.controls)); + return refs; + } + case "controls": + return isControlsWidget(widget) + ? parameterRefsFromControls(widget.props.controls) + : []; + case "accordionUi": + return isAccordionUiWidget(widget) + ? widget.props.items.flatMap((item) => + item.widgets.flatMap(parameterRefsFromWidget), + ) + : []; + case "stackUi": + return isStackUiWidget(widget) + ? widget.props.widgets.flatMap(parameterRefsFromWidget) + : []; + case "text": + case "image": + case "roundChart": + case "lineChart": + case "areaChart": + case "barChart": + case "actions": + case "attributeVisualization": + case "agent": + case "progress": + case "desktopClientSelection": + case "desktopClientOutputs": + case "savedStates": + case "sceneTreeExplorer": + case "table": + return []; + default: { + const unhandledType: never = widget.type; + return assertNever(unhandledType); } - refs.push(...parameterRefsFromControls(widget.props.controls)); - } - - if (isControlsWidget(widget)) { - refs.push(...parameterRefsFromControls(widget.props.controls)); } - - return refs; } /** * Returns all parameter references placed in the App Builder UI layout. * - * Walks every container and tab, collecting refs from accordion, form, and - * controls widgets. Used to determine which session parameters are shown in + * Walks every container and tab, collecting refs from accordion, form, controls, + * and nested stack/accordion-ui widgets. Used to determine which session parameters * the configurator (e.g. WebMCP `filter: "visible"`, in-app agent context). * * @param data - Parsed App Builder settings / layout tree. From 7103379d4335a6c2dd3d0ba3c13b47a5800e7907 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 10 Jul 2026 10:56:20 +0300 Subject: [PATCH 06/20] SS-9745: Make widget types as constants --- features/appbuilder/config/appbuilder.ts | 47 +++++++++++++----------- features/appbuilder/lib/appbuilder.ts | 43 +++++++++++----------- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/features/appbuilder/config/appbuilder.ts b/features/appbuilder/config/appbuilder.ts index 25a96dae..7cc41770 100644 --- a/features/appbuilder/config/appbuilder.ts +++ b/features/appbuilder/config/appbuilder.ts @@ -780,28 +780,33 @@ export interface IAppBuilderLegacyActionDefinition { | IAppBuilderLegacyActionPropsMessageToParent; } +/** Literal identifiers for {@link IAppBuilderWidget} `type` values. */ +export const AppBuilderWidgetType = { + Accordion: "accordion", + Text: "text", + Image: "image", + RoundChart: "roundChart", + LineChart: "lineChart", + AreaChart: "areaChart", + BarChart: "barChart", + Actions: "actions", + AttributeVisualization: "attributeVisualization", + Agent: "agent", + Progress: "progress", + DesktopClientSelection: "desktopClientSelection", + DesktopClientOutputs: "desktopClientOutputs", + Controls: "controls", + Form: "form", + AccordionUi: "accordionUi", + SavedStates: "savedStates", + SceneTreeExplorer: "sceneTreeExplorer", + StackUi: "stackUi", + Table: "table", +} as const; + /** Types of widgets */ export type AppBuilderWidgetType = - | "accordion" - | "text" - | "image" - | "roundChart" - | "lineChart" - | "areaChart" - | "barChart" - | "actions" - | "attributeVisualization" - | "agent" - | "progress" - | "desktopClientSelection" - | "desktopClientOutputs" - | "controls" - | "form" - | "accordionUi" - | "savedStates" - | "sceneTreeExplorer" - | "stackUi" - | "table"; + (typeof AppBuilderWidgetType)[keyof typeof AppBuilderWidgetType]; /** * Properties of a parameter and export accordion widget. @@ -1039,7 +1044,7 @@ export interface IAppBuilderWidgetPropsStackUi { * * When implementing a new widget type, extend this interface and * - * * add the identifier for the new type to AppBuilderWidgetType, and + * * add the identifier for the new type to {@link AppBuilderWidgetType}, and * * define a new interface for the properties of the widget type and * add it to the union type of "props", and * * add a matching `case` in diff --git a/features/appbuilder/lib/appbuilder.ts b/features/appbuilder/lib/appbuilder.ts index 31f50a23..e9209c6a 100644 --- a/features/appbuilder/lib/appbuilder.ts +++ b/features/appbuilder/lib/appbuilder.ts @@ -1,4 +1,5 @@ import { + AppBuilderWidgetType, IAppBuilder, IAppBuilderControl, IAppBuilderParameterRef, @@ -87,11 +88,11 @@ function parameterRefsFromWidget( widget: IAppBuilderWidget, ): IAppBuilderParameterRef[] { switch (widget.type) { - case "accordion": + case AppBuilderWidgetType.Accordion: return isAccordionWidget(widget) && widget.props.parameters ? [...widget.props.parameters] : []; - case "form": { + case AppBuilderWidgetType.Form: { if (!isFormWidget(widget)) { return []; } @@ -102,35 +103,35 @@ function parameterRefsFromWidget( refs.push(...parameterRefsFromControls(widget.props.controls)); return refs; } - case "controls": + case AppBuilderWidgetType.Controls: return isControlsWidget(widget) ? parameterRefsFromControls(widget.props.controls) : []; - case "accordionUi": + case AppBuilderWidgetType.AccordionUi: return isAccordionUiWidget(widget) ? widget.props.items.flatMap((item) => item.widgets.flatMap(parameterRefsFromWidget), ) : []; - case "stackUi": + case AppBuilderWidgetType.StackUi: return isStackUiWidget(widget) ? widget.props.widgets.flatMap(parameterRefsFromWidget) : []; - case "text": - case "image": - case "roundChart": - case "lineChart": - case "areaChart": - case "barChart": - case "actions": - case "attributeVisualization": - case "agent": - case "progress": - case "desktopClientSelection": - case "desktopClientOutputs": - case "savedStates": - case "sceneTreeExplorer": - case "table": + case AppBuilderWidgetType.Text: + case AppBuilderWidgetType.Image: + case AppBuilderWidgetType.RoundChart: + case AppBuilderWidgetType.LineChart: + case AppBuilderWidgetType.AreaChart: + case AppBuilderWidgetType.BarChart: + case AppBuilderWidgetType.Actions: + case AppBuilderWidgetType.AttributeVisualization: + case AppBuilderWidgetType.Agent: + case AppBuilderWidgetType.Progress: + case AppBuilderWidgetType.DesktopClientSelection: + case AppBuilderWidgetType.DesktopClientOutputs: + case AppBuilderWidgetType.SavedStates: + case AppBuilderWidgetType.SceneTreeExplorer: + case AppBuilderWidgetType.Table: return []; default: { const unhandledType: never = widget.type; @@ -143,7 +144,7 @@ function parameterRefsFromWidget( * Returns all parameter references placed in the App Builder UI layout. * * Walks every container and tab, collecting refs from accordion, form, controls, - * and nested stack/accordion-ui widgets. Used to determine which session parameters + * and nested stack/accordion-ui widgets. Used to determine which session parameters are shown in * the configurator (e.g. WebMCP `filter: "visible"`, in-app agent context). * * @param data - Parsed App Builder settings / layout tree. From e4ee72dc05237e92489292b991e3238b693d38c3 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 10 Jul 2026 12:05:32 +0300 Subject: [PATCH 07/20] SS-9745: Improve mcp parameters validation --- .../setParameterValues.feedback.test.ts | 10 +- features/webmcp/config/setParameterValues.ts | 265 ++---------------- features/webmcp/evals/runWebmcpEvals.ts | 6 +- features/webmcp/lib/findParameterByName.ts | 13 + .../webmcp/lib/resolveSetParameterUpdates.ts | 71 +++++ .../boolParameterValueValidator.ts | 35 +++ .../colorParameterValueValidator.ts | 46 +++ .../isColorObject.ts | 12 + .../numericParameterValueValidator.ts | 54 ++++ .../prepareParameterStoreValue.ts | 67 +++++ .../stringListParameterValueValidator.ts | 44 +++ .../stringParameterValueValidator.ts | 44 +++ .../lib/setParameterValueValidators/types.ts | 8 + features/webmcp/model/useWebMcpTools.ts | 6 +- 14 files changed, 425 insertions(+), 256 deletions(-) create mode 100644 features/webmcp/lib/findParameterByName.ts create mode 100644 features/webmcp/lib/resolveSetParameterUpdates.ts create mode 100644 features/webmcp/lib/setParameterValueValidators/boolParameterValueValidator.ts create mode 100644 features/webmcp/lib/setParameterValueValidators/colorParameterValueValidator.ts create mode 100644 features/webmcp/lib/setParameterValueValidators/isColorObject.ts create mode 100644 features/webmcp/lib/setParameterValueValidators/numericParameterValueValidator.ts create mode 100644 features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts create mode 100644 features/webmcp/lib/setParameterValueValidators/stringListParameterValueValidator.ts create mode 100644 features/webmcp/lib/setParameterValueValidators/stringParameterValueValidator.ts create mode 100644 features/webmcp/lib/setParameterValueValidators/types.ts diff --git a/features/webmcp/__tests__/setParameterValues.feedback.test.ts b/features/webmcp/__tests__/setParameterValues.feedback.test.ts index 2cbde27e..03103975 100644 --- a/features/webmcp/__tests__/setParameterValues.feedback.test.ts +++ b/features/webmcp/__tests__/setParameterValues.feedback.test.ts @@ -1,7 +1,7 @@ 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 "../config/setParameterValues"; +import {resolveAndUpdate} from "../lib/resolveSetParameterUpdates"; function createMockParameter( overrides: Partial> & { @@ -32,9 +32,7 @@ function createMockParameter( describe("resolveAndUpdate", () => { const namespace = "session-1"; - const batchParameterValueUpdate = jest - .fn() - .mockResolvedValue(undefined); + const batchParameterValueUpdate = jest.fn().mockResolvedValue(undefined); const getParametersFor = (paramsByNamespace: Record[]>) => @@ -177,7 +175,9 @@ describe("resolveAndUpdate", () => { type: ResParameterType.COLOR, defval: "0x000000ff", } as IShapeDiverParameter["definition"], - state: {uiValue: "0x000000ff"} as IShapeDiverParameter["state"], + state: { + uiValue: "0x000000ff", + } as IShapeDiverParameter["state"], actions: { isValid: (value: unknown) => value === composed, } as IShapeDiverParameter["actions"], diff --git a/features/webmcp/config/setParameterValues.ts b/features/webmcp/config/setParameterValues.ts index ca63efb7..ed6dc8a0 100644 --- a/features/webmcp/config/setParameterValues.ts +++ b/features/webmcp/config/setParameterValues.ts @@ -1,40 +1,20 @@ -import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; -import type {IShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/config/shapediverStoreParameters"; -import { - composeSdColor, - type DecomposedColorFormat, -} from "@AppBuilderLib/shared/lib/colors"; -import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; import {z} from "zod"; -import { - parseStringListIndex, - toStringListStoreValue, -} from "../lib/stringListValue"; -import {parameterValueSchema, SUPPORTED_PARAMETER_TYPES} from "./listParameterDefinitions"; +import type {ParameterValueInput} from "../lib/setParameterValueValidators/types"; +import {parameterValueSchema} from "./listParameterDefinitions"; -export const setParameterValuesInputSchema = z.strictObject({ - updates: z - .array( - 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}.", - ), - }), - ) +const setParameterUpdateSchema = z.strictObject({ + name: z + .string() .describe( - "Required array of changes. Use this field name exactly — not parameters or ids.", + "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 setParameterValuesErrorSchema = z.object({ @@ -42,6 +22,14 @@ export const setParameterValuesErrorSchema = z.object({ message: z.string(), }); +export const setParameterValuesInputSchema = z.strictObject({ + updates: z + .array(setParameterUpdateSchema) + .describe( + "Required array of changes. Use this field name exactly — not parameters or ids.", + ), +}); + export const setParameterValuesOutputSchema = z.object({ applied: z.array(z.string()), errors: z.array(setParameterValuesErrorSchema), @@ -57,213 +45,4 @@ export type SetParameterValuesError = z.infer< typeof setParameterValuesErrorSchema >; export type ParameterUpdateInput = SetParameterValuesInput["updates"][number]; -export type ParameterValueInput = z.infer; - -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 - ); -} - -function findParameter( - parameters: IShapeDiverParameter[], - name: string, -): IShapeDiverParameter | undefined { - return parameters.find( - (p) => - p.definition.id === name || - p.definition.name === name || - p.definition.displayname === name, - ); -} - -function getValidationErrorMessage( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): string { - const def = parameter.definition; - const type = def.type; - - if (isColorObject(value) && type !== ResParameterType.COLOR) { - return "Color object value is only valid for Color parameters."; - } - - if (type === ResParameterType.STRINGLIST) { - const choices = def.choices ?? []; - const index = parseStringListIndex(value); - if (index === undefined) { - return `Value type does not match parameter type ${type}. Use a 0-based integer index.`; - } - - return `Index ${index} is not valid (choices: 0..${Math.max(choices.length - 1, 0)}).`; - } - - if (type === ResParameterType.COLOR) { - if (!isColorObject(value)) { - return `Value type does not match parameter type ${type}.`; - } - - return `New color ${JSON.stringify(value)} is not valid for parameter.`; - } - - if ( - type === ResParameterType.EVEN || - type === ResParameterType.ODD || - type === ResParameterType.INT || - type === ResParameterType.FLOAT - ) { - if (typeof value !== "number") { - return `Value type does not match parameter type ${type}.`; - } - const min = def.min ?? null; - const max = def.max ?? null; - - return `Value ${value} is out of range [${min}, ${max}].`; - } - - if (type === ResParameterType.STRING) { - if (typeof value !== "string") { - return `Value type does not match parameter type ${type}.`; - } - - return `String value exceeds maximum length of ${def.max}.`; - } - - if (type === ResParameterType.BOOL) { - if (typeof value !== "boolean") { - return `Value type does not match parameter type ${type}.`; - } - } - - return `Value ${value} is not valid for parameter.`; -} - -/** - * Pure validation and batch-update logic for set_parameter_values. - */ -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 parameters = getParameters(targetNamespace); - const parameter = findParameter(parameters, 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); - - const def = parameter.definition; - const {value} = update; - - if (!SUPPORTED_PARAMETER_TYPES.includes(def.type)) { - errors.push({ - name: update.name, - message: `Parameter type "${def.type}" is not supported for setting via WebMCP.`, - }); - continue; - } - - if (isColorObject(value) && def.type !== ResParameterType.COLOR) { - errors.push({ - name: update.name, - message: - "Color object value is only valid for Color parameters.", - }); - continue; - } - - let preparedValue: unknown; - if (def.type === ResParameterType.COLOR) { - if (!isColorObject(value)) { - errors.push({ - name: update.name, - message: `Value type does not match parameter type ${def.type}.`, - }); - continue; - } - preparedValue = composeSdColor(value); - if (!parameter.actions.isValid(preparedValue, false)) { - errors.push({ - name: update.name, - message: getValidationErrorMessage(parameter, value), - }); - continue; - } - } else if (def.type === ResParameterType.STRINGLIST) { - const storeValue = toStringListStoreValue(value); - if (storeValue === undefined) { - errors.push({ - name: update.name, - message: `Value type does not match parameter type ${def.type}. Use a 0-based integer index.`, - }); - continue; - } - if (!parameter.actions.isValid(storeValue, false)) { - errors.push({ - name: update.name, - message: getValidationErrorMessage(parameter, value), - }); - continue; - } - preparedValue = storeValue; - } else { - if (isColorObject(value)) { - errors.push({ - name: update.name, - message: - "Color object value is only valid for Color parameters.", - }); - continue; - } - if (!parameter.actions.isValid(value, false)) { - errors.push({ - name: update.name, - message: getValidationErrorMessage(parameter, value), - }); - continue; - } - preparedValue = value; - } - - if (!valuesByNamespace[targetNamespace]) { - valuesByNamespace[targetNamespace] = {}; - } - valuesByNamespace[targetNamespace][paramId] = preparedValue; - } - - const applied = Object.values(valuesByNamespace).flatMap((values) => - Object.keys(values), - ); - - if (applied.length > 0) { - await batchUpdate(valuesByNamespace); - } - - return {applied, errors}; -} +export type {ParameterValueInput}; diff --git a/features/webmcp/evals/runWebmcpEvals.ts b/features/webmcp/evals/runWebmcpEvals.ts index 4a33b3b9..79a530b7 100644 --- a/features/webmcp/evals/runWebmcpEvals.ts +++ b/features/webmcp/evals/runWebmcpEvals.ts @@ -9,13 +9,11 @@ import { listParameterDefinitionsInputSchema, listParameterDefinitionsOutputSchema, } from "../config/listParameterDefinitions"; -import { - resolveAndUpdate, - setParameterValuesInputSchema, -} from "../config/setParameterValues"; +import {setParameterValuesInputSchema} from "../config/setParameterValues"; import {filterVisibleParameters} from "../lib/filterVisibleParameters"; import {formatToolInputError} from "../lib/formatToolInputError"; import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; +import {resolveAndUpdate} from "../lib/resolveSetParameterUpdates"; import { allParameters, EVAL_NAMESPACE, 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/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/boolParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/boolParameterValueValidator.ts new file mode 100644 index 00000000..3c6c0c52 --- /dev/null +++ b/features/webmcp/lib/setParameterValueValidators/boolParameterValueValidator.ts @@ -0,0 +1,35 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; + +export function getBoolValidationErrorMessage( + parameter: IShapeDiverParameter, + _value: ParameterValueInput, +): string { + return `Value type does not match parameter type ${parameter.definition.type}.`; +} + +export function prepareBoolParameterValue( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): ParameterValuePrepareResult { + if (typeof value !== "boolean") { + return { + success: false, + message: getBoolValidationErrorMessage(parameter, value), + }; + } + + if (!parameter.actions.isValid(value, false)) { + return { + success: false, + message: `Value ${value} is not valid for parameter.`, + }; + } + + return {success: true, storeValue: value}; +} + +export function isBoolParameterType(type: string): boolean { + return type === ResParameterType.BOOL; +} diff --git a/features/webmcp/lib/setParameterValueValidators/colorParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/colorParameterValueValidator.ts new file mode 100644 index 00000000..d2bb5a9d --- /dev/null +++ b/features/webmcp/lib/setParameterValueValidators/colorParameterValueValidator.ts @@ -0,0 +1,46 @@ +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 {isColorObject} from "./isColorObject"; +import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; + +export function getColorValidationErrorMessage( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): string { + const type = parameter.definition.type; + + if (!isColorObject(value)) { + return `Value type does not match parameter type ${type}.`; + } + + return `New color ${JSON.stringify(value)} is not valid for parameter.`; +} + +export function prepareColorParameterValue( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): ParameterValuePrepareResult { + const type = parameter.definition.type; + + if (!isColorObject(value)) { + return { + success: false, + message: `Value type does not match parameter type ${type}.`, + }; + } + + const storeValue = composeSdColor(value); + if (!parameter.actions.isValid(storeValue, false)) { + return { + success: false, + message: getColorValidationErrorMessage(parameter, value), + }; + } + + return {success: true, storeValue}; +} + +export function isColorParameterType(type: string): boolean { + return type === ResParameterType.COLOR; +} diff --git a/features/webmcp/lib/setParameterValueValidators/isColorObject.ts b/features/webmcp/lib/setParameterValueValidators/isColorObject.ts new file mode 100644 index 00000000..17bf5acc --- /dev/null +++ b/features/webmcp/lib/setParameterValueValidators/isColorObject.ts @@ -0,0 +1,12 @@ +import type {DecomposedColorFormat} from "@AppBuilderLib/shared/lib/colors"; + +export 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 + ); +} diff --git a/features/webmcp/lib/setParameterValueValidators/numericParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/numericParameterValueValidator.ts new file mode 100644 index 00000000..04bff6a1 --- /dev/null +++ b/features/webmcp/lib/setParameterValueValidators/numericParameterValueValidator.ts @@ -0,0 +1,54 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; + +const NUMERIC_PARAMETER_TYPES: ResParameterType[] = [ + ResParameterType.EVEN, + ResParameterType.ODD, + ResParameterType.INT, + ResParameterType.FLOAT, +]; + +export function isNumericParameterType(type: string): boolean { + return NUMERIC_PARAMETER_TYPES.includes(type as ResParameterType); +} + +export function getNumericValidationErrorMessage( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): string { + const def = parameter.definition; + const type = def.type; + + if (typeof value !== "number") { + return `Value type does not match parameter type ${type}.`; + } + + const min = def.min ?? null; + const max = def.max ?? null; + + return `Value ${value} is out of range [${min}, ${max}].`; +} + +export function prepareNumericParameterValue( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): ParameterValuePrepareResult { + const type = parameter.definition.type; + + if (typeof value !== "number") { + return { + success: false, + message: `Value type does not match parameter type ${type}.`, + }; + } + + if (!parameter.actions.isValid(value, false)) { + return { + success: false, + message: getNumericValidationErrorMessage(parameter, value), + }; + } + + return {success: true, storeValue: value}; +} diff --git a/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts b/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts new file mode 100644 index 00000000..be340bab --- /dev/null +++ b/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts @@ -0,0 +1,67 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import { + isBoolParameterType, + prepareBoolParameterValue, +} from "./boolParameterValueValidator"; +import { + isColorParameterType, + prepareColorParameterValue, +} from "./colorParameterValueValidator"; +import {isColorObject} from "./isColorObject"; +import { + isNumericParameterType, + prepareNumericParameterValue, +} from "./numericParameterValueValidator"; +import {prepareStringListParameterValue} from "./stringListParameterValueValidator"; +import { + isStringParameterType, + prepareStringParameterValue, +} from "./stringParameterValueValidator"; +import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; + +const COLOR_ON_NON_COLOR_MESSAGE = + "Color object value is only valid for Color parameters."; + +/** + * Validates agent input and returns the store value ShapeDiver expects. + */ +export function prepareParameterStoreValue( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): ParameterValuePrepareResult { + const type = parameter.definition.type; + + if (isColorObject(value) && !isColorParameterType(type)) { + return {success: false, message: COLOR_ON_NON_COLOR_MESSAGE}; + } + + if (isColorParameterType(type)) { + return prepareColorParameterValue(parameter, value); + } + + if (type === ResParameterType.STRINGLIST) { + return prepareStringListParameterValue(parameter, value); + } + + if (isBoolParameterType(type)) { + return prepareBoolParameterValue(parameter, value); + } + + if (isStringParameterType(type)) { + return prepareStringParameterValue(parameter, value); + } + + if (isNumericParameterType(type)) { + return prepareNumericParameterValue(parameter, value); + } + + if (!parameter.actions.isValid(value, false)) { + return { + success: false, + message: `Value ${value} is not valid for parameter.`, + }; + } + + return {success: true, storeValue: value}; +} diff --git a/features/webmcp/lib/setParameterValueValidators/stringListParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/stringListParameterValueValidator.ts new file mode 100644 index 00000000..0c5889c7 --- /dev/null +++ b/features/webmcp/lib/setParameterValueValidators/stringListParameterValueValidator.ts @@ -0,0 +1,44 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {parseStringListIndex, toStringListStoreValue} from "../stringListValue"; +import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; + +export function getStringListValidationErrorMessage( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): string { + const def = parameter.definition; + const type = def.type; + const index = parseStringListIndex(value); + + if (index === undefined) { + return `Value type does not match parameter type ${type}. Use a 0-based integer index.`; + } + + const choices = def.choices ?? []; + + return `Index ${index} is not valid (choices: 0..${Math.max(choices.length - 1, 0)}).`; +} + +export function prepareStringListParameterValue( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): ParameterValuePrepareResult { + const type = parameter.definition.type; + const storeValue = toStringListStoreValue(value); + + if (storeValue === undefined) { + return { + success: false, + message: `Value type does not match parameter type ${type}. Use a 0-based integer index.`, + }; + } + + if (!parameter.actions.isValid(storeValue, false)) { + return { + success: false, + message: getStringListValidationErrorMessage(parameter, value), + }; + } + + return {success: true, storeValue}; +} diff --git a/features/webmcp/lib/setParameterValueValidators/stringParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/stringParameterValueValidator.ts new file mode 100644 index 00000000..c3702ae5 --- /dev/null +++ b/features/webmcp/lib/setParameterValueValidators/stringParameterValueValidator.ts @@ -0,0 +1,44 @@ +import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; +import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; + +export function getStringValidationErrorMessage( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): string { + const def = parameter.definition; + const type = def.type; + + if (typeof value !== "string") { + return `Value type does not match parameter type ${type}.`; + } + + return `String value exceeds maximum length of ${def.max}.`; +} + +export function prepareStringParameterValue( + parameter: IShapeDiverParameter, + value: ParameterValueInput, +): ParameterValuePrepareResult { + const type = parameter.definition.type; + + if (typeof value !== "string") { + return { + success: false, + message: `Value type does not match parameter type ${type}.`, + }; + } + + if (!parameter.actions.isValid(value, false)) { + return { + success: false, + message: getStringValidationErrorMessage(parameter, value), + }; + } + + return {success: true, storeValue: value}; +} + +export function isStringParameterType(type: string): boolean { + return type === ResParameterType.STRING; +} 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/model/useWebMcpTools.ts b/features/webmcp/model/useWebMcpTools.ts index cac572d7..923d7f48 100644 --- a/features/webmcp/model/useWebMcpTools.ts +++ b/features/webmcp/model/useWebMcpTools.ts @@ -10,10 +10,7 @@ import {useShallow} from "zustand/react/shallow"; import {createModelStateInputSchema} from "../config/createModelState"; import {importModelStateInputSchema} from "../config/importModelState"; import {listParameterDefinitionsInputSchema} from "../config/listParameterDefinitions"; -import { - resolveAndUpdate, - setParameterValuesInputSchema, -} from "../config/setParameterValues"; +import {setParameterValuesInputSchema} from "../config/setParameterValues"; import { CREATE_MODEL_STATE_TOOL_DESCRIPTION, CREATE_MODEL_STATE_TOOL_NAME, @@ -28,6 +25,7 @@ import {computeAppliedParameterIds} from "../lib/computeAppliedParameterIds"; import {filterVisibleParameters} from "../lib/filterVisibleParameters"; import {formatToolInputError} from "../lib/formatToolInputError"; import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; +import {resolveAndUpdate} from "../lib/resolveSetParameterUpdates"; import { getModelContext, getWebMcpEnvironment, From 4cbd6c4d527267d690a18b1b69b3fad499725825 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 10 Jul 2026 12:31:49 +0300 Subject: [PATCH 08/20] SS-9745: Simplify schema generation for WebMCP --- features/webmcp/lib/zodToJsonSchema.ts | 123 ++++++++++++++----------- 1 file changed, 68 insertions(+), 55 deletions(-) diff --git a/features/webmcp/lib/zodToJsonSchema.ts b/features/webmcp/lib/zodToJsonSchema.ts index ed09564a..1bd35dfb 100644 --- a/features/webmcp/lib/zodToJsonSchema.ts +++ b/features/webmcp/lib/zodToJsonSchema.ts @@ -1,5 +1,6 @@ import {z} from "zod"; +/** JSON Schema subset accepted by WebMCP `registerTool({ inputSchema })`. */ export type JsonSchema = { type?: string; description?: string; @@ -12,6 +13,7 @@ export type JsonSchema = { const?: string | number | boolean; }; +/** Zod 4 internal definition shape (not public API). */ type ZodDef = { type: string; shape?: Record; @@ -20,14 +22,16 @@ type ZodDef = { element?: z.ZodType; entries?: Record; values?: (string | number | boolean)[]; - keyType?: z.ZodType; 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; @@ -51,16 +55,64 @@ function withDescription( return jsonSchema; } -function zodToJsonSchemaInner(schema: z.ZodType): 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 "string": - return withDescription(schema, {type: "string"}); - case "number": - return withDescription(schema, {type: "number"}); - case "boolean": - return withDescription(schema, {type: "boolean"}); case "any": return {}; case "enum": @@ -68,68 +120,29 @@ function zodToJsonSchemaInner(schema: z.ZodType): JsonSchema { type: "string", enum: Object.values(def.entries ?? {}), }); - case "literal": { - const value = def.values?.[0]; - 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}; - } + case "literal": + return literalSchema(def.values?.[0]); case "array": return { type: "array", - items: def.element ? zodToJsonSchemaInner(def.element) : {}, + items: def.element ? zodToJsonSchema(def.element) : {}, }; - case "object": { - const properties: Record = {}; - const required: string[] = []; - - for (const [key, value] of Object.entries(def.shape ?? {})) { - const {schema: inner, optional} = unwrapOptional(value); - properties[key] = zodToJsonSchemaInner(inner); - if (!optional) { - required.push(key); - } - } - - const result: JsonSchema = { - type: "object", - properties, - additionalProperties: false, - }; - if (required.length > 0) { - result.required = required; - } - - return withDescription(schema, result); - } + case "object": + return objectSchema(def.shape ?? {}, schema); case "union": return { - anyOf: (def.options ?? []).map((option) => - zodToJsonSchemaInner(option), - ), + anyOf: (def.options ?? []).map(zodToJsonSchema), }; case "record": return { type: "object", additionalProperties: def.valueType - ? zodToJsonSchemaInner(def.valueType) + ? zodToJsonSchema(def.valueType) : true, }; case "optional": - return def.innerType ? zodToJsonSchemaInner(def.innerType) : {}; + return def.innerType ? zodToJsonSchema(def.innerType) : {}; default: throw new Error(`Unsupported Zod type: ${def.type}`); } } - -export function zodToJsonSchema(schema: z.ZodType): JsonSchema { - return zodToJsonSchemaInner(schema); -} From 9e018bb61aa94d0a9304601875a969b82ad4501d Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 10 Jul 2026 12:50:32 +0300 Subject: [PATCH 09/20] SS-9745: Optimize mcp tools registration --- .../tools/registerCreateModelStateTool.ts | 55 ++++ .../tools/registerImportModelStateTool.ts | 70 +++++ .../registerListParameterDefinitionsTool.ts | 87 ++++++ .../tools/registerSetParameterValuesTool.ts | 46 +++ features/webmcp/model/useWebMcpTools.ts | 282 ++---------------- features/webmcp/model/webMcpToolsDeps.ts | 27 ++ 6 files changed, 316 insertions(+), 251 deletions(-) create mode 100644 features/webmcp/model/tools/registerCreateModelStateTool.ts create mode 100644 features/webmcp/model/tools/registerImportModelStateTool.ts create mode 100644 features/webmcp/model/tools/registerListParameterDefinitionsTool.ts create mode 100644 features/webmcp/model/tools/registerSetParameterValuesTool.ts create mode 100644 features/webmcp/model/webMcpToolsDeps.ts 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..64f5f04e --- /dev/null +++ b/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts @@ -0,0 +1,87 @@ +import {getParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; +import {listParameterDefinitionsInputSchema} from "../../config/listParameterDefinitions"; +import { + LIST_PARAMETER_DEFINITIONS_TOOL_DESCRIPTION, + LIST_PARAMETER_DEFINITIONS_TOOL_NAME, +} from "../../config/tools"; +import {filterVisibleParameters} from "../../lib/filterVisibleParameters"; +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"; + +function findParameterRef( + deps: WebMcpToolsDeps, + paramId: string, + paramName: string, + displayname?: string, +) { + const refs = deps.appBuilderDataRef.current + ? getParameterRefs(deps.appBuilderDataRef.current) + : []; + + return refs.find( + (ref) => + ref.name === paramId || + ref.name === paramName || + ref.name === displayname, + ); +} + +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") { + const refs = deps.appBuilderDataRef.current + ? getParameterRefs(deps.appBuilderDataRef.current) + : []; + parameters = filterVisibleParameters(parameters, refs); + } + + return { + parameters: parameters.map((param) => { + const def = param.definition; + + return mapParameterDefinition( + param, + findParameterRef( + deps, + def.id, + def.name, + def.displayname, + ), + ); + }), + }; + } 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 index 923d7f48..2541f94e 100644 --- a/features/webmcp/model/useWebMcpTools.ts +++ b/features/webmcp/model/useWebMcpTools.ts @@ -1,41 +1,24 @@ -import {getParameterStates} from "@AppBuilderLib/entities/parameter/lib/parameterStates"; import {useShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/model/useShapeDiverStoreParameters"; import {useShapeDiverStoreSession} from "@AppBuilderLib/entities/session/model/useShapeDiverStoreSession"; import {AppBuilderDataContext} from "@AppBuilderLib/features/appbuilder/lib/AppBuilderContext"; -import {getParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; import {useCreateModelState} from "@AppBuilderLib/features/model-state/model/useCreateModelState"; import {useImportModelState} from "@AppBuilderLib/features/model-state/model/useImportModelState"; import {useContext, useEffect, useRef, useState} from "react"; import {useShallow} from "zustand/react/shallow"; -import {createModelStateInputSchema} from "../config/createModelState"; -import {importModelStateInputSchema} from "../config/importModelState"; -import {listParameterDefinitionsInputSchema} from "../config/listParameterDefinitions"; -import {setParameterValuesInputSchema} from "../config/setParameterValues"; -import { - CREATE_MODEL_STATE_TOOL_DESCRIPTION, - CREATE_MODEL_STATE_TOOL_NAME, - IMPORT_MODEL_STATE_TOOL_DESCRIPTION, - IMPORT_MODEL_STATE_TOOL_NAME, - LIST_PARAMETER_DEFINITIONS_TOOL_DESCRIPTION, - LIST_PARAMETER_DEFINITIONS_TOOL_NAME, - SET_PARAMETER_VALUES_TOOL_DESCRIPTION, - SET_PARAMETER_VALUES_TOOL_NAME, -} from "../config/tools"; -import {computeAppliedParameterIds} from "../lib/computeAppliedParameterIds"; -import {filterVisibleParameters} from "../lib/filterVisibleParameters"; -import {formatToolInputError} from "../lib/formatToolInputError"; -import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; -import {resolveAndUpdate} from "../lib/resolveSetParameterUpdates"; import { getModelContext, getWebMcpEnvironment, isWebMcpAvailable, } from "../lib/webmcpAvailability"; -import {zodToJsonSchema} from "../lib/zodToJsonSchema"; +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, @@ -103,244 +86,41 @@ export function useWebMcpTools( const controller = new AbortController(); let cancelled = false; - const getLiveParameters = (targetNamespace: string) => { - const paramStores = getParametersRef.current(targetNamespace); - - return Object.values(paramStores).map((store) => store.getState()); - }; - - const findParameterRef = ( - paramId: string, - paramName: string, - displayname?: string, - ) => { - const refs = appBuilderDataRef.current - ? getParameterRefs(appBuilderDataRef.current) - : []; - - return refs.find( - (ref) => - ref.name === paramId || - ref.name === paramName || - ref.name === displayname, - ); + const deps: WebMcpToolsDeps = { + namespaceRef, + appBuilderDataRef, + getLiveParameters: (targetNamespace) => + Object.values(getParametersRef.current(targetNamespace)).map( + (store) => store.getState(), + ), + batchParameterValueUpdateRef, + createModelStateRef, + importModelStateRef, }; const registerTools = async () => { const modelContext = getModelContext(); try { - 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 ?? namespaceRef.current; - let parameters = - getLiveParameters(targetNamespace); - - if (filter === "visible") { - const refs = appBuilderDataRef.current - ? getParameterRefs( - appBuilderDataRef.current, - ) - : []; - parameters = filterVisibleParameters( - parameters, - refs, - ); - } - - return { - parameters: parameters.map((param) => { - const def = param.definition; - const ref = findParameterRef( - def.id, - def.name, - def.displayname, - ); - - return mapParameterDefinition( - param, - ref, - ); - }), - }; - } catch (e) { - return { - parameters: [], - ...formatToolInputError(e), - }; - } - }, - }, - {signal: controller.signal}, + await registerListParameterDefinitionsTool( + modelContext, + deps, + controller.signal, ); - - 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); - const targetNamespace = namespaceRef.current; - - return await resolveAndUpdate( - targetNamespace, - getLiveParameters, - parsed.updates, - batchParameterValueUpdateRef.current, - ); - } catch (e) { - return { - applied: [], - ...formatToolInputError(e), - }; - } - }, - }, - {signal: controller.signal}, + await registerSetParameterValuesTool( + modelContext, + deps, + controller.signal, ); - - 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 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: controller.signal}, + await registerCreateModelStateTool( + modelContext, + deps, + controller.signal, ); - - 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 = namespaceRef.current; - const beforeValues = new Map( - getParameterStates(targetNamespace).map( - (p) => [ - p.definition.id, - p.state.uiValue, - ], - ), - ); - const result = - await importModelStateRef.current(parsed); - - if (!result.success) { - return { - success: false as const, - message: result.message, - invalidParameters: - result.invalidParameters ?? [], - }; - } - - const afterParams = - getParameterStates(targetNamespace); - const appliedParameterIds = - computeAppliedParameterIds( - beforeValues, - afterParams, - ); - - 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: controller.signal}, + await registerImportModelStateTool( + modelContext, + deps, + controller.signal, ); if (!cancelled) { diff --git a/features/webmcp/model/webMcpToolsDeps.ts b/features/webmcp/model/webMcpToolsDeps.ts new file mode 100644 index 00000000..b28d9fbf --- /dev/null +++ b/features/webmcp/model/webMcpToolsDeps.ts @@ -0,0 +1,27 @@ +import type {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; +import type {IShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/config/shapediverStoreParameters"; +import type {IAppBuilder} from "@AppBuilderLib/features/appbuilder/config/appbuilder"; +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; + appBuilderDataRef: MutableRefObject; + getLiveParameters: (namespace: string) => IShapeDiverParameter[]; + batchParameterValueUpdateRef: MutableRefObject< + IShapeDiverStoreParameters["batchParameterValueUpdate"] + >; + createModelStateRef: MutableRefObject< + (props: ICreateModelStateData) => Promise + >; + importModelStateRef: MutableRefObject< + (props: IImportModelStateData) => Promise + >; +} From 0aa75dfa3b4b11bdcbe3e12a9e81a4bf81a61d14 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Wed, 15 Jul 2026 11:37:36 +0300 Subject: [PATCH 10/20] SS-9745: PR review fixes --- features/appbuilder/config/appbuilder.ts | 2 +- ...efs.test.ts => getUiParameterRefs.test.ts} | 27 ++++--- features/appbuilder/lib/appbuilder.ts | 8 +- features/appbuilder/model/useAgent.ts | 4 +- .../setParameterValues.feedback.test.ts | 2 +- .../webmcp/evals/__fixtures__/parameters.ts | 73 ++++-------------- features/webmcp/evals/evals.json | 8 +- .../boolParameterValueValidator.ts | 35 --------- .../colorParameterValueValidator.ts | 46 ----------- .../isColorObject.ts | 12 --- .../numericParameterValueValidator.ts | 54 ------------- .../prepareParameterStoreValue.ts | 76 ++++++++----------- .../stringListParameterValueValidator.ts | 44 ----------- .../stringParameterValueValidator.ts | 44 ----------- .../registerListParameterDefinitionsTool.ts | 6 +- 15 files changed, 77 insertions(+), 364 deletions(-) rename features/appbuilder/lib/__tests__/{getParameterRefs.test.ts => getUiParameterRefs.test.ts} (83%) delete mode 100644 features/webmcp/lib/setParameterValueValidators/boolParameterValueValidator.ts delete mode 100644 features/webmcp/lib/setParameterValueValidators/colorParameterValueValidator.ts delete mode 100644 features/webmcp/lib/setParameterValueValidators/isColorObject.ts delete mode 100644 features/webmcp/lib/setParameterValueValidators/numericParameterValueValidator.ts delete mode 100644 features/webmcp/lib/setParameterValueValidators/stringListParameterValueValidator.ts delete mode 100644 features/webmcp/lib/setParameterValueValidators/stringParameterValueValidator.ts diff --git a/features/appbuilder/config/appbuilder.ts b/features/appbuilder/config/appbuilder.ts index 7cc41770..8e6a9a79 100644 --- a/features/appbuilder/config/appbuilder.ts +++ b/features/appbuilder/config/appbuilder.ts @@ -1048,7 +1048,7 @@ export interface IAppBuilderWidgetPropsStackUi { * * define a new interface for the properties of the widget type and * add it to the union type of "props", and * * add a matching `case` in - * {@link getParameterRefs @AppBuilderLib/features/appbuilder/lib/appbuilder} + * {@link getUiParameterRefs @AppBuilderLib/features/appbuilder/lib/appbuilder} * (exhaustive `switch` — TypeScript errors if a type is missing). */ export interface IAppBuilderWidget { diff --git a/features/appbuilder/lib/__tests__/getParameterRefs.test.ts b/features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts similarity index 83% rename from features/appbuilder/lib/__tests__/getParameterRefs.test.ts rename to features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts index fa91813e..c21775b0 100644 --- a/features/appbuilder/lib/__tests__/getParameterRefs.test.ts +++ b/features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts @@ -2,18 +2,16 @@ import { AppBuilderContainerNameType, IAppBuilder, } from "../../config/appbuilder"; -import {getParameterRefs} from "../appbuilder"; +import {getUiParameterRefs} from "../appbuilder"; -function minimalAppBuilder( - containers: IAppBuilder["containers"], -): IAppBuilder { +function minimalAppBuilder(containers: IAppBuilder["containers"]): IAppBuilder { return { version: "1.0", containers, }; } -describe("getParameterRefs", () => { +describe("getUiParameterRefs", () => { it("collects refs from accordion widgets in containers and tabs", () => { const data = minimalAppBuilder([ { @@ -38,7 +36,7 @@ describe("getParameterRefs", () => { }, ]); - expect(getParameterRefs(data).map((ref) => ref.name)).toEqual([ + expect(getUiParameterRefs(data).map((ref) => ref.name)).toEqual([ "width", "height", ]); @@ -54,7 +52,10 @@ describe("getParameterRefs", () => { props: { parameters: [{name: "form-param"}], controls: [ - {type: "parameter", props: {name: "control-param"}}, + { + type: "parameter", + props: {name: "control-param"}, + }, {type: "export", props: {name: "some-export"}}, ], }, @@ -63,7 +64,7 @@ describe("getParameterRefs", () => { }, ]); - expect(getParameterRefs(data).map((ref) => ref.name)).toEqual([ + expect(getUiParameterRefs(data).map((ref) => ref.name)).toEqual([ "form-param", "control-param", ]); @@ -92,7 +93,7 @@ describe("getParameterRefs", () => { }, ]); - expect(getParameterRefs(data)).toEqual([ + expect(getUiParameterRefs(data)).toEqual([ {name: "slider", sessionId: "session-a"}, ]); }); @@ -126,7 +127,7 @@ describe("getParameterRefs", () => { }, ]); - expect(getParameterRefs(data)).toEqual([ + expect(getUiParameterRefs(data)).toEqual([ {name: "width"}, {name: "width", sessionId: "other-session"}, ]); @@ -160,7 +161,7 @@ describe("getParameterRefs", () => { }, ]); - expect(getParameterRefs(data).map((ref) => ref.name)).toEqual([ + expect(getUiParameterRefs(data).map((ref) => ref.name)).toEqual([ "nested-slider", ]); }); @@ -192,6 +193,8 @@ describe("getParameterRefs", () => { }, ]); - expect(getParameterRefs(data).map((ref) => ref.name)).toEqual(["depth"]); + expect(getUiParameterRefs(data).map((ref) => ref.name)).toEqual([ + "depth", + ]); }); }); diff --git a/features/appbuilder/lib/appbuilder.ts b/features/appbuilder/lib/appbuilder.ts index e9209c6a..7a071489 100644 --- a/features/appbuilder/lib/appbuilder.ts +++ b/features/appbuilder/lib/appbuilder.ts @@ -18,14 +18,14 @@ import { * TypeScript reports an error on the `never` assignment in the default branch. */ function assertNever(value: never): never { - throw new Error(`Unhandled widget type: ${String(value)}`); + throw new Error(`Unhandled case: ${String(value)}`); } /** * Stable deduplication key for a parameter reference. * Combines optional `sessionId` and `name` so the same parameter in different sessions stays distinct. */ -function refKey( +function parameterRefDedupeKey( ref: Pick, ): string { return `${ref.sessionId ?? ""}\0${ref.name}`; @@ -42,7 +42,7 @@ function dedupeParameterRefs( const result: IAppBuilderParameterRef[] = []; for (const ref of refs) { - const key = refKey(ref); + const key = parameterRefDedupeKey(ref); if (seen.has(key)) { continue; } @@ -150,7 +150,7 @@ function parameterRefsFromWidget( * @param data - Parsed App Builder settings / layout tree. * @returns Deduplicated refs ordered by first appearance in the layout. */ -export function getParameterRefs(data: IAppBuilder): IAppBuilderParameterRef[] { +export function getUiParameterRefs(data: IAppBuilder): IAppBuilderParameterRef[] { const refs: IAppBuilderParameterRef[] = []; for (const container of data.containers) { diff --git a/features/appbuilder/model/useAgent.ts b/features/appbuilder/model/useAgent.ts index dd8ce9ae..29707ddf 100644 --- a/features/appbuilder/model/useAgent.ts +++ b/features/appbuilder/model/useAgent.ts @@ -3,7 +3,7 @@ import type {IShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter import {useAllParametersStateless} from "@AppBuilderLib/entities/parameter/model/useAllParametersStateless"; import {useShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/model/useShapeDiverStoreParameters"; import {AppBuilderDataContext} from "@AppBuilderLib/features/appbuilder/lib/AppBuilderContext"; -import {getParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; +import {getUiParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; import {useNotificationStore} from "@AppBuilderLib/features/notifications/model/useNotificationStore"; import { composeSdColor, @@ -415,7 +415,7 @@ export function useAgent(props: Props) { // get App Builder data, we need it to extract parameter tooltips const {data: appBuilderData} = useContext(AppBuilderDataContext); const parameterRefs = useMemo( - () => (appBuilderData ? getParameterRefs(appBuilderData) : []), + () => (appBuilderData ? getUiParameterRefs(appBuilderData) : []), [appBuilderData], ); diff --git a/features/webmcp/__tests__/setParameterValues.feedback.test.ts b/features/webmcp/__tests__/setParameterValues.feedback.test.ts index 03103975..a3cbe3ef 100644 --- a/features/webmcp/__tests__/setParameterValues.feedback.test.ts +++ b/features/webmcp/__tests__/setParameterValues.feedback.test.ts @@ -118,7 +118,7 @@ describe("resolveAndUpdate", () => { errors: [ { name: "Width", - message: "Value 200 is out of range [0, 100].", + message: "Value 200 is not valid for parameter.", }, ], }); diff --git a/features/webmcp/evals/__fixtures__/parameters.ts b/features/webmcp/evals/__fixtures__/parameters.ts index d4ffda27..f3020a1a 100644 --- a/features/webmcp/evals/__fixtures__/parameters.ts +++ b/features/webmcp/evals/__fixtures__/parameters.ts @@ -5,69 +5,11 @@ import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; export const EVAL_NAMESPACE = "eval-session"; -function createIsValid( - def: IShapeDiverParameter["definition"], -): IShapeDiverParameter["actions"]["isValid"] { - return (value: unknown) => { - const type = def.type; - - if ( - type === ResParameterType.INT || - type === ResParameterType.FLOAT || - type === ResParameterType.EVEN || - type === ResParameterType.ODD - ) { - if (typeof value !== "number") { - return false; - } - const min = def.min ?? Number.NEGATIVE_INFINITY; - const max = def.max ?? Number.POSITIVE_INFINITY; - - return value >= min && value <= max; - } - - if (type === ResParameterType.STRINGLIST) { - if (typeof value !== "string" && typeof value !== "number") { - return false; - } - const index = typeof value === "number" ? value : Number(value); - const choices = def.choices ?? []; - - return ( - Number.isInteger(index) && index >= 0 && index < choices.length - ); - } - - if (type === ResParameterType.COLOR) { - return typeof value === "string" && value.length > 0; - } - - if (type === ResParameterType.STRING) { - if (typeof value !== "string") { - return false; - } - if (def.max !== undefined) { - return value.length <= def.max; - } - - return true; - } - - if (type === ResParameterType.BOOL) { - return typeof value === "boolean"; - } - - return true; - }; -} - function createMockParameter( overrides: Partial> & { definition: IShapeDiverParameter["definition"]; }, ): IShapeDiverParameter { - const isValid = createIsValid(overrides.definition); - return { state: { uiValue: overrides.state?.uiValue, @@ -80,7 +22,7 @@ function createMockParameter( setUiValue: () => true, setUiAndExecValue: () => true, execute: async () => "", - isValid, + isValid: overrides.actions?.isValid ?? (() => true), isUiValueDifferent: () => false, resetToDefaultValue: () => undefined, resetToExecValue: () => undefined, @@ -103,6 +45,10 @@ export const intParameter = createMockParameter({ 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({ @@ -114,6 +60,15 @@ export const stringListParameter = createMockParameter({ 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({ diff --git a/features/webmcp/evals/evals.json b/features/webmcp/evals/evals.json index 84cc405b..ef590eb5 100644 --- a/features/webmcp/evals/evals.json +++ b/features/webmcp/evals/evals.json @@ -61,7 +61,7 @@ "expect": { "applied": [], "errorsNonEmpty": true, - "errorMessageIncludes": "does not match parameter type" + "errorMessageIncludes": "not valid for parameter" } }, { @@ -147,7 +147,7 @@ "expect": { "applied": [], "errorsNonEmpty": true, - "errorMessageIncludes": "0-based integer index" + "errorMessageIncludes": "not valid for parameter" } }, { @@ -185,7 +185,7 @@ "expect": { "applied": [], "errorCount": 2, - "errorMessageIncludesAll": ["0-based integer index", "out of range"] + "errorMessageIncludesAll": ["not valid"] } }, { @@ -198,7 +198,7 @@ "expect": { "applied": [], "errorsNonEmpty": true, - "errorMessageIncludes": "0-based integer index" + "errorMessageIncludes": "not valid for parameter" } }, { diff --git a/features/webmcp/lib/setParameterValueValidators/boolParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/boolParameterValueValidator.ts deleted file mode 100644 index 3c6c0c52..00000000 --- a/features/webmcp/lib/setParameterValueValidators/boolParameterValueValidator.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; -import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; -import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; - -export function getBoolValidationErrorMessage( - parameter: IShapeDiverParameter, - _value: ParameterValueInput, -): string { - return `Value type does not match parameter type ${parameter.definition.type}.`; -} - -export function prepareBoolParameterValue( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): ParameterValuePrepareResult { - if (typeof value !== "boolean") { - return { - success: false, - message: getBoolValidationErrorMessage(parameter, value), - }; - } - - if (!parameter.actions.isValid(value, false)) { - return { - success: false, - message: `Value ${value} is not valid for parameter.`, - }; - } - - return {success: true, storeValue: value}; -} - -export function isBoolParameterType(type: string): boolean { - return type === ResParameterType.BOOL; -} diff --git a/features/webmcp/lib/setParameterValueValidators/colorParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/colorParameterValueValidator.ts deleted file mode 100644 index d2bb5a9d..00000000 --- a/features/webmcp/lib/setParameterValueValidators/colorParameterValueValidator.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 {isColorObject} from "./isColorObject"; -import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; - -export function getColorValidationErrorMessage( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): string { - const type = parameter.definition.type; - - if (!isColorObject(value)) { - return `Value type does not match parameter type ${type}.`; - } - - return `New color ${JSON.stringify(value)} is not valid for parameter.`; -} - -export function prepareColorParameterValue( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): ParameterValuePrepareResult { - const type = parameter.definition.type; - - if (!isColorObject(value)) { - return { - success: false, - message: `Value type does not match parameter type ${type}.`, - }; - } - - const storeValue = composeSdColor(value); - if (!parameter.actions.isValid(storeValue, false)) { - return { - success: false, - message: getColorValidationErrorMessage(parameter, value), - }; - } - - return {success: true, storeValue}; -} - -export function isColorParameterType(type: string): boolean { - return type === ResParameterType.COLOR; -} diff --git a/features/webmcp/lib/setParameterValueValidators/isColorObject.ts b/features/webmcp/lib/setParameterValueValidators/isColorObject.ts deleted file mode 100644 index 17bf5acc..00000000 --- a/features/webmcp/lib/setParameterValueValidators/isColorObject.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type {DecomposedColorFormat} from "@AppBuilderLib/shared/lib/colors"; - -export 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 - ); -} diff --git a/features/webmcp/lib/setParameterValueValidators/numericParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/numericParameterValueValidator.ts deleted file mode 100644 index 04bff6a1..00000000 --- a/features/webmcp/lib/setParameterValueValidators/numericParameterValueValidator.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; -import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; -import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; - -const NUMERIC_PARAMETER_TYPES: ResParameterType[] = [ - ResParameterType.EVEN, - ResParameterType.ODD, - ResParameterType.INT, - ResParameterType.FLOAT, -]; - -export function isNumericParameterType(type: string): boolean { - return NUMERIC_PARAMETER_TYPES.includes(type as ResParameterType); -} - -export function getNumericValidationErrorMessage( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): string { - const def = parameter.definition; - const type = def.type; - - if (typeof value !== "number") { - return `Value type does not match parameter type ${type}.`; - } - - const min = def.min ?? null; - const max = def.max ?? null; - - return `Value ${value} is out of range [${min}, ${max}].`; -} - -export function prepareNumericParameterValue( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): ParameterValuePrepareResult { - const type = parameter.definition.type; - - if (typeof value !== "number") { - return { - success: false, - message: `Value type does not match parameter type ${type}.`, - }; - } - - if (!parameter.actions.isValid(value, false)) { - return { - success: false, - message: getNumericValidationErrorMessage(parameter, value), - }; - } - - return {success: true, storeValue: value}; -} diff --git a/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts b/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts index be340bab..dd786b76 100644 --- a/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts +++ b/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts @@ -1,28 +1,28 @@ 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 { - isBoolParameterType, - prepareBoolParameterValue, -} from "./boolParameterValueValidator"; -import { - isColorParameterType, - prepareColorParameterValue, -} from "./colorParameterValueValidator"; -import {isColorObject} from "./isColorObject"; -import { - isNumericParameterType, - prepareNumericParameterValue, -} from "./numericParameterValueValidator"; -import {prepareStringListParameterValue} from "./stringListParameterValueValidator"; -import { - isStringParameterType, - prepareStringParameterValue, -} from "./stringParameterValueValidator"; +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."; +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 + ); +} + +function invalidValueMessage(value: ParameterValueInput): string { + return `Value ${JSON.stringify(value)} is not valid for parameter.`; +} + /** * Validates agent input and returns the store value ShapeDiver expects. */ @@ -32,36 +32,26 @@ export function prepareParameterStoreValue( ): ParameterValuePrepareResult { const type = parameter.definition.type; - if (isColorObject(value) && !isColorParameterType(type)) { + if (isColorObject(value) && type !== ResParameterType.COLOR) { return {success: false, message: COLOR_ON_NON_COLOR_MESSAGE}; } - if (isColorParameterType(type)) { - return prepareColorParameterValue(parameter, value); - } - - if (type === ResParameterType.STRINGLIST) { - return prepareStringListParameterValue(parameter, value); - } - - if (isBoolParameterType(type)) { - return prepareBoolParameterValue(parameter, value); - } - - if (isStringParameterType(type)) { - return prepareStringParameterValue(parameter, value); - } - - if (isNumericParameterType(type)) { - return prepareNumericParameterValue(parameter, value); + 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(value)}; + } + } else { + storeValue = value; } - if (!parameter.actions.isValid(value, false)) { - return { - success: false, - message: `Value ${value} is not valid for parameter.`, - }; + if (!parameter.actions.isValid(storeValue, false)) { + return {success: false, message: invalidValueMessage(value)}; } - return {success: true, storeValue: value}; + return {success: true, storeValue}; } diff --git a/features/webmcp/lib/setParameterValueValidators/stringListParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/stringListParameterValueValidator.ts deleted file mode 100644 index 0c5889c7..00000000 --- a/features/webmcp/lib/setParameterValueValidators/stringListParameterValueValidator.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; -import {parseStringListIndex, toStringListStoreValue} from "../stringListValue"; -import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; - -export function getStringListValidationErrorMessage( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): string { - const def = parameter.definition; - const type = def.type; - const index = parseStringListIndex(value); - - if (index === undefined) { - return `Value type does not match parameter type ${type}. Use a 0-based integer index.`; - } - - const choices = def.choices ?? []; - - return `Index ${index} is not valid (choices: 0..${Math.max(choices.length - 1, 0)}).`; -} - -export function prepareStringListParameterValue( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): ParameterValuePrepareResult { - const type = parameter.definition.type; - const storeValue = toStringListStoreValue(value); - - if (storeValue === undefined) { - return { - success: false, - message: `Value type does not match parameter type ${type}. Use a 0-based integer index.`, - }; - } - - if (!parameter.actions.isValid(storeValue, false)) { - return { - success: false, - message: getStringListValidationErrorMessage(parameter, value), - }; - } - - return {success: true, storeValue}; -} diff --git a/features/webmcp/lib/setParameterValueValidators/stringParameterValueValidator.ts b/features/webmcp/lib/setParameterValueValidators/stringParameterValueValidator.ts deleted file mode 100644 index c3702ae5..00000000 --- a/features/webmcp/lib/setParameterValueValidators/stringParameterValueValidator.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; -import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; -import type {ParameterValueInput, ParameterValuePrepareResult} from "./types"; - -export function getStringValidationErrorMessage( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): string { - const def = parameter.definition; - const type = def.type; - - if (typeof value !== "string") { - return `Value type does not match parameter type ${type}.`; - } - - return `String value exceeds maximum length of ${def.max}.`; -} - -export function prepareStringParameterValue( - parameter: IShapeDiverParameter, - value: ParameterValueInput, -): ParameterValuePrepareResult { - const type = parameter.definition.type; - - if (typeof value !== "string") { - return { - success: false, - message: `Value type does not match parameter type ${type}.`, - }; - } - - if (!parameter.actions.isValid(value, false)) { - return { - success: false, - message: getStringValidationErrorMessage(parameter, value), - }; - } - - return {success: true, storeValue: value}; -} - -export function isStringParameterType(type: string): boolean { - return type === ResParameterType.STRING; -} diff --git a/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts b/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts index 64f5f04e..b01780f0 100644 --- a/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts +++ b/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts @@ -1,4 +1,4 @@ -import {getParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; +import {getUiParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; import {listParameterDefinitionsInputSchema} from "../../config/listParameterDefinitions"; import { LIST_PARAMETER_DEFINITIONS_TOOL_DESCRIPTION, @@ -18,7 +18,7 @@ function findParameterRef( displayname?: string, ) { const refs = deps.appBuilderDataRef.current - ? getParameterRefs(deps.appBuilderDataRef.current) + ? getUiParameterRefs(deps.appBuilderDataRef.current) : []; return refs.find( @@ -54,7 +54,7 @@ export async function registerListParameterDefinitionsTool( if (filter === "visible") { const refs = deps.appBuilderDataRef.current - ? getParameterRefs(deps.appBuilderDataRef.current) + ? getUiParameterRefs(deps.appBuilderDataRef.current) : []; parameters = filterVisibleParameters(parameters, refs); } From 9734beb4b98b722436e7e40cf12fa31cafcde81f Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Wed, 15 Jul 2026 14:16:44 +0300 Subject: [PATCH 11/20] SS-9745: PR review fixes --- .../__tests__/filterVisibleParameters.test.ts | 35 ---- .../parameterDefinitionMapper.test.ts | 18 +- .../setParameterValues.feedback.test.ts | 3 +- .../webmcp/config/listParameterDefinitions.ts | 2 +- .../webmcp/evals/__fixtures__/parameters.ts | 8 +- features/webmcp/evals/agent_scenarios.md | 155 ++++++++++++++++++ features/webmcp/evals/evals.json | 22 ++- features/webmcp/evals/runWebmcpEvals.ts | 22 +-- .../webmcp/lib/filterVisibleParameters.ts | 31 ---- .../webmcp/lib/parameterDefinitionMapper.ts | 17 +- .../prepareParameterStoreValue.ts | 78 ++++++++- .../registerListParameterDefinitionsTool.ts | 43 +---- features/webmcp/model/useWebMcpTools.ts | 9 +- features/webmcp/model/webMcpToolsDeps.ts | 2 - 14 files changed, 268 insertions(+), 177 deletions(-) delete mode 100644 features/webmcp/__tests__/filterVisibleParameters.test.ts create mode 100644 features/webmcp/evals/agent_scenarios.md delete mode 100644 features/webmcp/lib/filterVisibleParameters.ts diff --git a/features/webmcp/__tests__/filterVisibleParameters.test.ts b/features/webmcp/__tests__/filterVisibleParameters.test.ts deleted file mode 100644 index 1c0cf32a..00000000 --- a/features/webmcp/__tests__/filterVisibleParameters.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; -import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; -import {filterVisibleParameters} from "../lib/filterVisibleParameters"; - -function mockParam(id: string, name: string): IShapeDiverParameter { - return { - definition: { - id, - name, - type: ResParameterType.FLOAT, - }, - } as IShapeDiverParameter; -} - -describe("filterVisibleParameters", () => { - it("returns empty array when layout refs are empty", () => { - const params = [mockParam("a", "Slider1"), mockParam("b", "Slider2")]; - - expect(filterVisibleParameters(params, [])).toEqual([]); - }); - - it("filters by ref name, id, or displayname", () => { - const params = [ - mockParam("id-1", "Slider1"), - mockParam("id-2", "Hidden"), - ]; - - const filtered = filterVisibleParameters(params, [ - {name: "Slider1"}, - {name: "id-2"}, - ]); - - expect(filtered.map((p) => p.definition.id)).toEqual(["id-1", "id-2"]); - }); -}); diff --git a/features/webmcp/__tests__/parameterDefinitionMapper.test.ts b/features/webmcp/__tests__/parameterDefinitionMapper.test.ts index f74b9511..d8ff5ddd 100644 --- a/features/webmcp/__tests__/parameterDefinitionMapper.test.ts +++ b/features/webmcp/__tests__/parameterDefinitionMapper.test.ts @@ -124,7 +124,7 @@ describe("mapParameterDefinition", () => { }); }); - it("applies ref overrides for displayname/group/tooltip", () => { + it("includes group and tooltip from definition", () => { const param = createMockParameter({ definition: { id: "p1", @@ -140,23 +140,13 @@ describe("mapParameterDefinition", () => { state: {uiValue: 7} as IShapeDiverParameter["state"], }); - expect( - mapParameterDefinition(param, { - name: "width", - overrides: { - displayname: "Custom Width", - group: {name: "Sizing"}, - tooltip: "Override tooltip", - }, - }), - ).toEqual({ + expect(mapParameterDefinition(param)).toEqual({ id: "p1", name: "Width", - displayname: "Custom Width", type: ResParameterType.INT, settable: true, - group: "Sizing", - tooltip: "Override tooltip", + group: "Dimensions", + tooltip: "Model width", min: 0, max: 10, currentValue: 7, diff --git a/features/webmcp/__tests__/setParameterValues.feedback.test.ts b/features/webmcp/__tests__/setParameterValues.feedback.test.ts index a3cbe3ef..b024099a 100644 --- a/features/webmcp/__tests__/setParameterValues.feedback.test.ts +++ b/features/webmcp/__tests__/setParameterValues.feedback.test.ts @@ -118,7 +118,8 @@ describe("resolveAndUpdate", () => { errors: [ { name: "Width", - message: "Value 200 is not valid for parameter.", + message: + 'Value 200 is not valid for parameter "Width" (Float). Use a number in range [0, 100].', }, ], }); diff --git a/features/webmcp/config/listParameterDefinitions.ts b/features/webmcp/config/listParameterDefinitions.ts index 7203d427..a398f203 100644 --- a/features/webmcp/config/listParameterDefinitions.ts +++ b/features/webmcp/config/listParameterDefinitions.ts @@ -37,7 +37,7 @@ export const listParameterDefinitionsInputSchema = z.strictObject({ .enum(["all", "visible"]) .optional() .describe( - "all = every changeable parameter; visible = parameters shown in the configurator panel. Defaults to all.", + "all = every parameter; visible = parameters not hidden by the model (definition.hidden === false). Defaults to all.", ), sessionId: z .string() diff --git a/features/webmcp/evals/__fixtures__/parameters.ts b/features/webmcp/evals/__fixtures__/parameters.ts index f3020a1a..2cb5fb31 100644 --- a/features/webmcp/evals/__fixtures__/parameters.ts +++ b/features/webmcp/evals/__fixtures__/parameters.ts @@ -1,5 +1,4 @@ import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; -import {IAppBuilderParameterRef} from "@AppBuilderLib/features/appbuilder/config/appbuilder"; import {composeSdColor} from "@AppBuilderLib/shared/lib/colors"; import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; @@ -123,6 +122,7 @@ export const fileParameter = createMockParameter({ id: "upload-file", name: "Upload", type: ResParameterType.FILE, + hidden: true, } as unknown as IShapeDiverParameter["definition"], state: {uiValue: ""} as IShapeDiverParameter["state"], }); @@ -136,9 +136,3 @@ export const allParameters: IShapeDiverParameter[] = [ colorNamedStringListParameter, fileParameter, ]; - -/** Refs for the "visible" list filter — width + paint only. */ -export const parameterRefs: IAppBuilderParameterRef[] = [ - {name: "width-int"}, - {name: "Paint"}, -]; diff --git a/features/webmcp/evals/agent_scenarios.md b/features/webmcp/evals/agent_scenarios.md new file mode 100644 index 00000000..8ffab8c3 --- /dev/null +++ b/features/webmcp/evals/agent_scenarios.md @@ -0,0 +1,155 @@ +# 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. Call tools: `const r = await mc.executeTool("tool_name", JSON.stringify(input));` + - Second arg MUST be a JSON string. +4. `await mc.getTools()` lists registered tools (4 expected). + +Record per scenario: tool called, input sent, raw result, pass/fail vs expectation. + +## Conventions + +- Discover params first with `list_parameter_definitions` `{filter:"all"}` to learn real names/ids/types. +- "Width" = the INT/float width param; "Material" = StringList; "Paint" = Color; "Color" = a StringList named Color (trap). +- 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. diff --git a/features/webmcp/evals/evals.json b/features/webmcp/evals/evals.json index ef590eb5..35879c79 100644 --- a/features/webmcp/evals/evals.json +++ b/features/webmcp/evals/evals.json @@ -9,9 +9,19 @@ { "id": "list_visible", "tool": "list_parameter_definitions", - "description": "List only UI-visible parameters", + "description": "List only non-hidden parameters", "input": {"filter": "visible"}, - "expect": {"parametersCount": 2, "parameterIds": ["width-int", "paint-color"]} + "expect": { + "parametersCount": 6, + "parameterIds": [ + "width-int", + "material-list", + "paint-color", + "height-float", + "enabled-bool", + "color-list" + ] + } }, { "id": "list_reject_visible_only", @@ -36,7 +46,7 @@ "input": { "updates": [{"name": "Width", "value": 99}] }, - "expect": {"applied": [], "errorsNonEmpty": true, "appliedExcludes": ["width-int"]} + "expect": {"applied": [], "errorsNonEmpty": true, "appliedExcludes": ["width-int"], "errorMessageIncludes": "range"} }, { "id": "set_unknown_id", @@ -147,7 +157,7 @@ "expect": { "applied": [], "errorsNonEmpty": true, - "errorMessageIncludes": "not valid for parameter" + "errorMessageIncludes": "0-based integer index" } }, { @@ -185,7 +195,7 @@ "expect": { "applied": [], "errorCount": 2, - "errorMessageIncludesAll": ["not valid"] + "errorMessageIncludesAll": ["0-based integer index", "range"] } }, { @@ -198,7 +208,7 @@ "expect": { "applied": [], "errorsNonEmpty": true, - "errorMessageIncludes": "not valid for parameter" + "errorMessageIncludes": "0-based integer index" } }, { diff --git a/features/webmcp/evals/runWebmcpEvals.ts b/features/webmcp/evals/runWebmcpEvals.ts index 79a530b7..75eedb27 100644 --- a/features/webmcp/evals/runWebmcpEvals.ts +++ b/features/webmcp/evals/runWebmcpEvals.ts @@ -10,15 +10,10 @@ import { listParameterDefinitionsOutputSchema, } from "../config/listParameterDefinitions"; import {setParameterValuesInputSchema} from "../config/setParameterValues"; -import {filterVisibleParameters} from "../lib/filterVisibleParameters"; import {formatToolInputError} from "../lib/formatToolInputError"; import {mapParameterDefinition} from "../lib/parameterDefinitionMapper"; import {resolveAndUpdate} from "../lib/resolveSetParameterUpdates"; -import { - allParameters, - EVAL_NAMESPACE, - parameterRefs, -} from "./__fixtures__/parameters"; +import {allParameters, EVAL_NAMESPACE} from "./__fixtures__/parameters"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -50,17 +45,6 @@ function loadScenarios(): EvalScenario[] { return JSON.parse(raw) as EvalScenario[]; } -function findParameterRef(param: (typeof allParameters)[number]) { - const def = param.definition; - - return parameterRefs.find( - (ref) => - ref.name === def.id || - ref.name === def.name || - (!!def.displayname && ref.name === def.displayname), - ); -} - function runListScenario(input: Record) { try { const parsed = listParameterDefinitionsInputSchema.parse(input); @@ -68,12 +52,12 @@ function runListScenario(input: Record) { let parameters = allParameters; if (filter === "visible") { - parameters = filterVisibleParameters(parameters, parameterRefs); + parameters = parameters.filter((p) => !p.definition.hidden); } return { parameters: parameters.map((param) => - mapParameterDefinition(param, findParameterRef(param)), + mapParameterDefinition(param), ), }; } catch (e) { diff --git a/features/webmcp/lib/filterVisibleParameters.ts b/features/webmcp/lib/filterVisibleParameters.ts deleted file mode 100644 index 42d095fc..00000000 --- a/features/webmcp/lib/filterVisibleParameters.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; -import {IAppBuilderParameterRef} from "@AppBuilderLib/features/appbuilder/config/appbuilder"; - -function parameterMatchesRef( - param: IShapeDiverParameter, - refs: IAppBuilderParameterRef[], -): boolean { - const def = param.definition; - - return refs.some( - (ref) => - ref.name === def.id || - ref.name === def.name || - (!!def.displayname && ref.name === def.displayname), - ); -} - -/** - * Parameters placed in the configurator UI (accordion, form, controls widgets). - * When layout has no parameter refs, returns empty — visible means UI-placed only. - */ -export function filterVisibleParameters( - parameters: IShapeDiverParameter[], - refs: IAppBuilderParameterRef[], -): IShapeDiverParameter[] { - if (refs.length === 0) { - return []; - } - - return parameters.filter((param) => parameterMatchesRef(param, refs)); -} diff --git a/features/webmcp/lib/parameterDefinitionMapper.ts b/features/webmcp/lib/parameterDefinitionMapper.ts index 0bbcd5fe..b4237fe4 100644 --- a/features/webmcp/lib/parameterDefinitionMapper.ts +++ b/features/webmcp/lib/parameterDefinitionMapper.ts @@ -1,5 +1,4 @@ import {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; -import {IAppBuilderParameterRef} from "@AppBuilderLib/features/appbuilder/config/appbuilder"; import {decomposeSdColor} from "@AppBuilderLib/shared/lib/colors"; import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; import { @@ -12,7 +11,6 @@ export type {ListParameterDefinitionItem}; export function mapParameterDefinition( param: IShapeDiverParameter, - ref?: IAppBuilderParameterRef, ): ListParameterDefinitionItem { const def = param.definition; const currentValue = param.state.uiValue; @@ -29,19 +27,16 @@ export function mapParameterDefinition( item.hidden = def.hidden; } - const displayname = ref?.overrides?.displayname; - if (displayname && displayname !== name) { - item.displayname = displayname; + if (def.displayname && def.displayname !== name) { + item.displayname = def.displayname; } - const groupName = ref?.overrides?.group?.name || def.group?.name; - if (groupName) { - item.group = groupName; + if (def.group?.name) { + item.group = def.group.name; } - const tooltip = ref?.overrides?.tooltip || def.tooltip; - if (tooltip) { - item.tooltip = tooltip; + if (def.tooltip) { + item.tooltip = def.tooltip; } if (def.type === ResParameterType.STRINGLIST) { diff --git a/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts b/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts index dd786b76..778964c5 100644 --- a/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts +++ b/features/webmcp/lib/setParameterValueValidators/prepareParameterStoreValue.ts @@ -8,6 +8,13 @@ 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" && @@ -19,12 +26,71 @@ function isColorObject(value: unknown): value is DecomposedColorFormat { ); } -function invalidValueMessage(value: ParameterValueInput): string { - return `Value ${JSON.stringify(value)} is not valid for parameter.`; +/** + * 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, @@ -37,20 +103,22 @@ export function prepareParameterStoreValue( } 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(value)}; + return { + success: false, + message: invalidValueMessage(parameter, value), + }; } } else { storeValue = value; } if (!parameter.actions.isValid(storeValue, false)) { - return {success: false, message: invalidValueMessage(value)}; + return {success: false, message: invalidValueMessage(parameter, value)}; } return {success: true, storeValue}; diff --git a/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts b/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts index b01780f0..a4fa31c5 100644 --- a/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts +++ b/features/webmcp/model/tools/registerListParameterDefinitionsTool.ts @@ -1,34 +1,14 @@ -import {getUiParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; import {listParameterDefinitionsInputSchema} from "../../config/listParameterDefinitions"; import { LIST_PARAMETER_DEFINITIONS_TOOL_DESCRIPTION, LIST_PARAMETER_DEFINITIONS_TOOL_NAME, } from "../../config/tools"; -import {filterVisibleParameters} from "../../lib/filterVisibleParameters"; 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"; -function findParameterRef( - deps: WebMcpToolsDeps, - paramId: string, - paramName: string, - displayname?: string, -) { - const refs = deps.appBuilderDataRef.current - ? getUiParameterRefs(deps.appBuilderDataRef.current) - : []; - - return refs.find( - (ref) => - ref.name === paramId || - ref.name === paramName || - ref.name === displayname, - ); -} - export async function registerListParameterDefinitionsTool( modelContext: ModelContext, deps: WebMcpToolsDeps, @@ -53,26 +33,15 @@ export async function registerListParameterDefinitionsTool( let parameters = deps.getLiveParameters(targetNamespace); if (filter === "visible") { - const refs = deps.appBuilderDataRef.current - ? getUiParameterRefs(deps.appBuilderDataRef.current) - : []; - parameters = filterVisibleParameters(parameters, refs); + parameters = parameters.filter( + (p) => !p.definition.hidden, + ); } return { - parameters: parameters.map((param) => { - const def = param.definition; - - return mapParameterDefinition( - param, - findParameterRef( - deps, - def.id, - def.name, - def.displayname, - ), - ); - }), + parameters: parameters.map((param) => + mapParameterDefinition(param), + ), }; } catch (e) { return { diff --git a/features/webmcp/model/useWebMcpTools.ts b/features/webmcp/model/useWebMcpTools.ts index 2541f94e..5227a63e 100644 --- a/features/webmcp/model/useWebMcpTools.ts +++ b/features/webmcp/model/useWebMcpTools.ts @@ -1,9 +1,8 @@ import {useShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/model/useShapeDiverStoreParameters"; import {useShapeDiverStoreSession} from "@AppBuilderLib/entities/session/model/useShapeDiverStoreSession"; -import {AppBuilderDataContext} from "@AppBuilderLib/features/appbuilder/lib/AppBuilderContext"; import {useCreateModelState} from "@AppBuilderLib/features/model-state/model/useCreateModelState"; import {useImportModelState} from "@AppBuilderLib/features/model-state/model/useImportModelState"; -import {useContext, useEffect, useRef, useState} from "react"; +import {useEffect, useRef, useState} from "react"; import {useShallow} from "zustand/react/shallow"; import { getModelContext, @@ -48,8 +47,6 @@ export function useWebMcpTools( const {importModelState} = useImportModelState({ namespace: namespace ?? "", }); - const {data: appBuilderData} = useContext(AppBuilderDataContext); - const namespaceRef = useRef(namespace ?? ""); namespaceRef.current = namespace ?? ""; @@ -65,9 +62,6 @@ export function useWebMcpTools( const importModelStateRef = useRef(importModelState); importModelStateRef.current = importModelState; - const appBuilderDataRef = useRef(appBuilderData); - appBuilderDataRef.current = appBuilderData; - const sessionReady = !!namespace && !!sessions[namespace]; const paramsPopulated = !!namespace && Object.keys(getParameters(namespace)).length > 0; @@ -88,7 +82,6 @@ export function useWebMcpTools( const deps: WebMcpToolsDeps = { namespaceRef, - appBuilderDataRef, getLiveParameters: (targetNamespace) => Object.values(getParametersRef.current(targetNamespace)).map( (store) => store.getState(), diff --git a/features/webmcp/model/webMcpToolsDeps.ts b/features/webmcp/model/webMcpToolsDeps.ts index b28d9fbf..2acea0dc 100644 --- a/features/webmcp/model/webMcpToolsDeps.ts +++ b/features/webmcp/model/webMcpToolsDeps.ts @@ -1,6 +1,5 @@ import type {IShapeDiverParameter} from "@AppBuilderLib/entities/parameter/config/parameter"; import type {IShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/config/shapediverStoreParameters"; -import type {IAppBuilder} from "@AppBuilderLib/features/appbuilder/config/appbuilder"; import type { ICreateModelStateData, ICreateModelStateResult, @@ -13,7 +12,6 @@ import type {MutableRefObject} from "react"; export interface WebMcpToolsDeps { namespaceRef: MutableRefObject; - appBuilderDataRef: MutableRefObject; getLiveParameters: (namespace: string) => IShapeDiverParameter[]; batchParameterValueUpdateRef: MutableRefObject< IShapeDiverStoreParameters["batchParameterValueUpdate"] From 78adc2eda65a5c55291cd90b468b22c4be8ce3d3 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Wed, 15 Jul 2026 14:36:57 +0300 Subject: [PATCH 12/20] SS-9745: Optimize getUiParameterRefs --- .../lib/__tests__/getUiParameterRefs.test.ts | 35 ----------------- features/appbuilder/lib/appbuilder.ts | 38 ++----------------- 2 files changed, 3 insertions(+), 70 deletions(-) diff --git a/features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts b/features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts index c21775b0..62df8601 100644 --- a/features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts +++ b/features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts @@ -98,41 +98,6 @@ describe("getUiParameterRefs", () => { ]); }); - it("deduplicates refs by name and sessionId", () => { - const data = minimalAppBuilder([ - { - name: AppBuilderContainerNameType.Left, - widgets: [ - { - type: "accordion", - props: {parameters: [{name: "width"}]}, - }, - { - type: "form", - props: { - parameters: [{name: "width"}], - controls: [ - {type: "parameter", props: {name: "width"}}, - { - type: "parameter", - props: { - name: "width", - sessionId: "other-session", - }, - }, - ], - }, - }, - ], - }, - ]); - - expect(getUiParameterRefs(data)).toEqual([ - {name: "width"}, - {name: "width", sessionId: "other-session"}, - ]); - }); - it("collects refs from widgets nested in stackUi", () => { const data = minimalAppBuilder([ { diff --git a/features/appbuilder/lib/appbuilder.ts b/features/appbuilder/lib/appbuilder.ts index 7a071489..459e08d4 100644 --- a/features/appbuilder/lib/appbuilder.ts +++ b/features/appbuilder/lib/appbuilder.ts @@ -21,38 +21,6 @@ function assertNever(value: never): never { throw new Error(`Unhandled case: ${String(value)}`); } -/** - * Stable deduplication key for a parameter reference. - * Combines optional `sessionId` and `name` so the same parameter in different sessions stays distinct. - */ -function parameterRefDedupeKey( - ref: Pick, -): string { - return `${ref.sessionId ?? ""}\0${ref.name}`; -} - -/** - * Removes duplicate parameter references while preserving first-seen order. - * Two refs are equal when both `name` and `sessionId` match. - */ -function dedupeParameterRefs( - refs: IAppBuilderParameterRef[], -): IAppBuilderParameterRef[] { - const seen = new Set(); - const result: IAppBuilderParameterRef[] = []; - - for (const ref of refs) { - const key = parameterRefDedupeKey(ref); - if (seen.has(key)) { - continue; - } - seen.add(key); - result.push(ref); - } - - return result; -} - /** * Maps parameter-type controls to {@link IAppBuilderParameterRef} entries. * Export, action, and output controls are ignored. @@ -145,10 +113,10 @@ function parameterRefsFromWidget( * * Walks every container and tab, collecting refs from accordion, form, controls, * and nested stack/accordion-ui widgets. Used to determine which session parameters are shown in - * the configurator (e.g. WebMCP `filter: "visible"`, in-app agent context). + * the configurator (e.g. in-app agent context). * * @param data - Parsed App Builder settings / layout tree. - * @returns Deduplicated refs ordered by first appearance in the layout. + * @returns Refs ordered by first appearance in the layout (duplicates preserved if a parameter is placed in multiple widgets). */ export function getUiParameterRefs(data: IAppBuilder): IAppBuilderParameterRef[] { const refs: IAppBuilderParameterRef[] = []; @@ -168,5 +136,5 @@ export function getUiParameterRefs(data: IAppBuilder): IAppBuilderParameterRef[] } } - return dedupeParameterRefs(refs); + return refs; } From dc725d4f422a73261019f1f124fb982892e118a3 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Wed, 15 Jul 2026 16:11:33 +0300 Subject: [PATCH 13/20] SS-9745: Remove excess function --- features/appbuilder/lib/appbuilder.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/features/appbuilder/lib/appbuilder.ts b/features/appbuilder/lib/appbuilder.ts index 459e08d4..54a4a414 100644 --- a/features/appbuilder/lib/appbuilder.ts +++ b/features/appbuilder/lib/appbuilder.ts @@ -12,15 +12,6 @@ import { isStackUiWidget, } from "../config/appbuilder"; -/** - * Compile-time exhaustiveness guard for {@link parameterRefsFromWidget}. - * If `AppBuilderWidgetType` gains a new member without a matching `switch` case, - * TypeScript reports an error on the `never` assignment in the default branch. - */ -function assertNever(value: never): never { - throw new Error(`Unhandled case: ${String(value)}`); -} - /** * Maps parameter-type controls to {@link IAppBuilderParameterRef} entries. * Export, action, and output controls are ignored. @@ -103,7 +94,7 @@ function parameterRefsFromWidget( return []; default: { const unhandledType: never = widget.type; - return assertNever(unhandledType); + throw new Error(`Unhandled widget type: ${String(unhandledType)}`); } } } @@ -118,7 +109,9 @@ function parameterRefsFromWidget( * @param data - Parsed App Builder settings / layout tree. * @returns Refs ordered by first appearance in the layout (duplicates preserved if a parameter is placed in multiple widgets). */ -export function getUiParameterRefs(data: IAppBuilder): IAppBuilderParameterRef[] { +export function getUiParameterRefs( + data: IAppBuilder, +): IAppBuilderParameterRef[] { const refs: IAppBuilderParameterRef[] = []; for (const container of data.containers) { From 779be53c833d4b621000af1f53f0a661dae87750 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Thu, 16 Jul 2026 11:36:54 +0300 Subject: [PATCH 14/20] SS-9745: Canonical createModelState Zod in model-state --- .../appbuilder/config/appbuildertypecheck.ts | 16 +++--- .../ecommerce/config/ecommerceapitypecheck.ts | 11 +--- .../model-state/config/createModelState.ts | 50 +++--------------- .../config/createModelState.zod.ts | 23 ++++++++ features/webmcp/config/createModelState.ts | 52 ++++++++++--------- 5 files changed, 68 insertions(+), 84 deletions(-) create mode 100644 features/model-state/config/createModelState.zod.ts diff --git a/features/appbuilder/config/appbuildertypecheck.ts b/features/appbuilder/config/appbuildertypecheck.ts index 2ce478aa..35002d13 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 {appBuilderThemeOtherPropsSchema} from "@AppBuilderLib/shared/mantine-props/appBuilderThemeOther.zod"; import {mantineThemeOverridePropsSchema} from "@AppBuilderLib/shared/mantine-props/themeOverride.zod"; import type {MantineTheme, MantineThemeComponent} from "@mantine/core"; @@ -395,15 +396,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 1d3014fe..cb652880 100644 --- a/features/ecommerce/config/ecommerceapitypecheck.ts +++ b/features/ecommerce/config/ecommerceapitypecheck.ts @@ -1,15 +1,8 @@ -import {IAppBuilderImageRefSchema} from "@AppBuilderShared/features/appbuilder/config/appbuildertypecheck"; +import {createModelStateDataSchema} from "@AppBuilderLib/features/model-state/config/createModelState.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); diff --git a/features/model-state/config/createModelState.ts b/features/model-state/config/createModelState.ts index 64c6bec7..7f349cd5 100644 --- a/features/model-state/config/createModelState.ts +++ b/features/model-state/config/createModelState.ts @@ -1,49 +1,15 @@ -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"; +export { + createModelStateCoreSchema, + createModelStateDataSchema, + createModelStateImageRefSchema, +} 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..83816c2c --- /dev/null +++ b/features/model-state/config/createModelState.zod.ts @@ -0,0 +1,23 @@ +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(), + parameterNamesToExclude: z.array(z.string()).optional(), + includeImage: z.boolean().optional(), + includeGltf: z.boolean().optional(), +}); + +export const createModelStateDataSchema = createModelStateCoreSchema.extend({ + image: createModelStateImageRefSchema.optional(), + data: z.record(z.string(), z.any()).optional(), +}); diff --git a/features/webmcp/config/createModelState.ts b/features/webmcp/config/createModelState.ts index 7dd02c0f..7625e5d5 100644 --- a/features/webmcp/config/createModelState.ts +++ b/features/webmcp/config/createModelState.ts @@ -1,29 +1,33 @@ +import {createModelStateDataSchema} from "@AppBuilderLib/features/model-state/config/createModelState.zod"; import {z} from "zod"; -export const createModelStateInputSchema = 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."), - data: z - .record(z.string(), z.any()) - .optional() - .describe("Optional custom metadata to store with the state."), -}); +/** WebMCP input = hook data without `image` (agents don't set export screenshot refs). */ +export const createModelStateInputSchema = createModelStateDataSchema + .omit({image: true}) + .extend({ + 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."), + data: z + .record(z.string(), z.any()) + .optional() + .describe("Optional custom metadata to store with the state."), + }); export const createModelStateSuccessOutputSchema = z.object({ success: z.literal(true), From d8c3afd97308e53f2ed917a70a3c33a2a5beb81f Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 17 Jul 2026 10:11:08 +0300 Subject: [PATCH 15/20] SS-9745: Fix types duplicate --- .../config/createModelState.zod.ts | 27 +++++++++++++---- features/webmcp/config/createModelState.ts | 29 ++----------------- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/features/model-state/config/createModelState.zod.ts b/features/model-state/config/createModelState.zod.ts index 83816c2c..52618630 100644 --- a/features/model-state/config/createModelState.zod.ts +++ b/features/model-state/config/createModelState.zod.ts @@ -11,13 +11,30 @@ export const createModelStateImageRefSchema = z.strictObject({ }); export const createModelStateCoreSchema = z.strictObject({ - parameterNamesToInclude: z.array(z.string()).optional(), - parameterNamesToExclude: z.array(z.string()).optional(), - includeImage: z.boolean().optional(), - includeGltf: z.boolean().optional(), + 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(), + data: z + .record(z.string(), z.any()) + .optional() + .describe("Optional custom metadata to store with the state."), }); diff --git a/features/webmcp/config/createModelState.ts b/features/webmcp/config/createModelState.ts index 7625e5d5..12c3740e 100644 --- a/features/webmcp/config/createModelState.ts +++ b/features/webmcp/config/createModelState.ts @@ -2,32 +2,9 @@ import {createModelStateDataSchema} from "@AppBuilderLib/features/model-state/co import {z} from "zod"; /** WebMCP input = hook data without `image` (agents don't set export screenshot refs). */ -export const createModelStateInputSchema = createModelStateDataSchema - .omit({image: true}) - .extend({ - 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."), - data: z - .record(z.string(), z.any()) - .optional() - .describe("Optional custom metadata to store with the state."), - }); +export const createModelStateInputSchema = createModelStateDataSchema.omit({ + image: true, +}); export const createModelStateSuccessOutputSchema = z.object({ success: z.literal(true), From 82f3c6de6938e11e20aa52d15eb6d07dd77cfbb8 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 17 Jul 2026 10:24:12 +0300 Subject: [PATCH 16/20] SS-9745: Oprimize types --- .../ecommerce/config/ecommerceapitypecheck.ts | 5 ++--- .../model-state/config/createModelState.ts | 5 ----- .../model-state/config/importModelState.ts | 18 ++++++++++++------ .../model-state/config/importModelState.zod.ts | 17 +++++++++++++++++ features/webmcp/config/importModelState.ts | 17 +++++------------ .../webmcp/config/listParameterDefinitions.ts | 6 ++---- features/webmcp/config/setParameterValues.ts | 6 ++---- 7 files changed, 40 insertions(+), 34 deletions(-) create mode 100644 features/model-state/config/importModelState.zod.ts diff --git a/features/ecommerce/config/ecommerceapitypecheck.ts b/features/ecommerce/config/ecommerceapitypecheck.ts index cb652880..1c62fba6 100644 --- a/features/ecommerce/config/ecommerceapitypecheck.ts +++ b/features/ecommerce/config/ecommerceapitypecheck.ts @@ -1,4 +1,5 @@ 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 @@ -9,9 +10,7 @@ export const validateCreateModelStateData = (value: any) => { }; // 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 7f349cd5..d118a116 100644 --- a/features/model-state/config/createModelState.ts +++ b/features/model-state/config/createModelState.ts @@ -1,10 +1,5 @@ import type {z} from "zod"; import {createModelStateDataSchema} from "./createModelState.zod"; -export { - createModelStateCoreSchema, - createModelStateDataSchema, - createModelStateImageRefSchema, -} from "./createModelState.zod"; /** * Data accepted by the useCreateModelState hook to create a model state. diff --git a/features/model-state/config/importModelState.ts b/features/model-state/config/importModelState.ts index 1144a2be..bbd4ff0e 100644 --- a/features/model-state/config/importModelState.ts +++ b/features/model-state/config/importModelState.ts @@ -1,12 +1,18 @@ import {ResGetModelState} from "@shapediver/sdk.geometry-api-sdk-v2"; +import type {z} from "zod"; +import { + importModelStateDataSchema, + importModelStateInvalidParameterSchema, +} 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; + +export type IImportModelStateInvalidParameter = z.infer< + typeof importModelStateInvalidParameterSchema +>; /** * Data returned from the useImportModelState hook. @@ -15,10 +21,10 @@ export type IImportModelStateResult = | { success: false; message: string; - invalidParameters?: Array<{name: string; message: string}>; + invalidParameters?: IImportModelStateInvalidParameter[]; } | { success: true; data: ResGetModelState; - invalidParameters?: Array<{name: string; message: string}>; + invalidParameters?: IImportModelStateInvalidParameter[]; }; diff --git a/features/model-state/config/importModelState.zod.ts b/features/model-state/config/importModelState.zod.ts new file mode 100644 index 00000000..92de9deb --- /dev/null +++ b/features/model-state/config/importModelState.zod.ts @@ -0,0 +1,17 @@ +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.", + ), +}); + +export const importModelStateInvalidParameterSchema = nameMessageSchema; diff --git a/features/webmcp/config/importModelState.ts b/features/webmcp/config/importModelState.ts index e4229b3f..a5ae3fca 100644 --- a/features/webmcp/config/importModelState.ts +++ b/features/webmcp/config/importModelState.ts @@ -1,17 +1,10 @@ +import { + importModelStateDataSchema, + importModelStateInvalidParameterSchema, +} from "@AppBuilderLib/features/model-state/config/importModelState.zod"; import {z} from "zod"; -export const importModelStateInputSchema = z.strictObject({ - modelStateId: z - .string() - .describe( - "modelStateId from create_model_state, or a full model view URL containing modelStateId.", - ), -}); - -export const importModelStateInvalidParameterSchema = z.object({ - name: z.string(), - message: z.string(), -}); +export const importModelStateInputSchema = importModelStateDataSchema; export const importModelStateSuccessOutputSchema = z.object({ success: z.literal(true), diff --git a/features/webmcp/config/listParameterDefinitions.ts b/features/webmcp/config/listParameterDefinitions.ts index a398f203..22e88746 100644 --- a/features/webmcp/config/listParameterDefinitions.ts +++ b/features/webmcp/config/listParameterDefinitions.ts @@ -1,3 +1,4 @@ +import {nameMessageSchema} from "@AppBuilderLib/features/model-state/config/importModelState.zod"; import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; import {z} from "zod"; @@ -45,10 +46,7 @@ export const listParameterDefinitionsInputSchema = z.strictObject({ .describe("Optional session namespace. Omit for the main model."), }); -export const listParameterDefinitionsErrorSchema = z.object({ - name: z.string(), - message: z.string(), -}); +export const listParameterDefinitionsErrorSchema = nameMessageSchema; export const listParameterDefinitionsOutputSchema = z.object({ parameters: z.array(ListParameterDefinitionItemSchema), diff --git a/features/webmcp/config/setParameterValues.ts b/features/webmcp/config/setParameterValues.ts index ed6dc8a0..ab2b43a9 100644 --- a/features/webmcp/config/setParameterValues.ts +++ b/features/webmcp/config/setParameterValues.ts @@ -1,3 +1,4 @@ +import {nameMessageSchema} from "@AppBuilderLib/features/model-state/config/importModelState.zod"; import {z} from "zod"; import type {ParameterValueInput} from "../lib/setParameterValueValidators/types"; import {parameterValueSchema} from "./listParameterDefinitions"; @@ -17,10 +18,7 @@ const setParameterUpdateSchema = z.strictObject({ ), }); -export const setParameterValuesErrorSchema = z.object({ - name: z.string(), - message: z.string(), -}); +export const setParameterValuesErrorSchema = nameMessageSchema; export const setParameterValuesInputSchema = z.strictObject({ updates: z From 9f9d04ca80fee2fd780759d10ea1e74711501309 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 17 Jul 2026 10:43:36 +0300 Subject: [PATCH 17/20] SS-9745: Rollback excess changes --- features/appbuilder/config/appbuilder.ts | 52 +++--- .../lib/__tests__/getUiParameterRefs.test.ts | 165 ------------------ features/appbuilder/lib/appbuilder.ts | 140 +++------------ features/appbuilder/model/useAgent.ts | 4 +- 4 files changed, 44 insertions(+), 317 deletions(-) delete mode 100644 features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts diff --git a/features/appbuilder/config/appbuilder.ts b/features/appbuilder/config/appbuilder.ts index 8e6a9a79..14fa08cd 100644 --- a/features/appbuilder/config/appbuilder.ts +++ b/features/appbuilder/config/appbuilder.ts @@ -780,33 +780,28 @@ export interface IAppBuilderLegacyActionDefinition { | IAppBuilderLegacyActionPropsMessageToParent; } -/** Literal identifiers for {@link IAppBuilderWidget} `type` values. */ -export const AppBuilderWidgetType = { - Accordion: "accordion", - Text: "text", - Image: "image", - RoundChart: "roundChart", - LineChart: "lineChart", - AreaChart: "areaChart", - BarChart: "barChart", - Actions: "actions", - AttributeVisualization: "attributeVisualization", - Agent: "agent", - Progress: "progress", - DesktopClientSelection: "desktopClientSelection", - DesktopClientOutputs: "desktopClientOutputs", - Controls: "controls", - Form: "form", - AccordionUi: "accordionUi", - SavedStates: "savedStates", - SceneTreeExplorer: "sceneTreeExplorer", - StackUi: "stackUi", - Table: "table", -} as const; - /** Types of widgets */ export type AppBuilderWidgetType = - (typeof AppBuilderWidgetType)[keyof typeof AppBuilderWidgetType]; + | "accordion" + | "text" + | "image" + | "roundChart" + | "lineChart" + | "areaChart" + | "barChart" + | "actions" + | "attributeVisualization" + | "agent" + | "progress" + | "desktopClientSelection" + | "desktopClientOutputs" + | "controls" + | "form" + | "accordionUi" + | "savedStates" + | "sceneTreeExplorer" + | "stackUi" + | "table"; /** * Properties of a parameter and export accordion widget. @@ -1044,12 +1039,9 @@ export interface IAppBuilderWidgetPropsStackUi { * * When implementing a new widget type, extend this interface and * - * * add the identifier for the new type to {@link AppBuilderWidgetType}, and + * * add the identifier for the new type to AppBuilderWidgetType, and * * define a new interface for the properties of the widget type and - * add it to the union type of "props", and - * * add a matching `case` in - * {@link getUiParameterRefs @AppBuilderLib/features/appbuilder/lib/appbuilder} - * (exhaustive `switch` — TypeScript errors if a type is missing). + * add it to the union type of "props". */ export interface IAppBuilderWidget { /** Type of the widget. */ diff --git a/features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts b/features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts deleted file mode 100644 index 62df8601..00000000 --- a/features/appbuilder/lib/__tests__/getUiParameterRefs.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { - AppBuilderContainerNameType, - IAppBuilder, -} from "../../config/appbuilder"; -import {getUiParameterRefs} from "../appbuilder"; - -function minimalAppBuilder(containers: IAppBuilder["containers"]): IAppBuilder { - return { - version: "1.0", - containers, - }; -} - -describe("getUiParameterRefs", () => { - it("collects refs from accordion widgets in containers and tabs", () => { - const data = minimalAppBuilder([ - { - name: AppBuilderContainerNameType.Left, - widgets: [ - { - type: "accordion", - props: {parameters: [{name: "width"}]}, - }, - ], - tabs: [ - { - name: "Tab 1", - widgets: [ - { - type: "accordion", - props: {parameters: [{name: "height"}]}, - }, - ], - }, - ], - }, - ]); - - expect(getUiParameterRefs(data).map((ref) => ref.name)).toEqual([ - "width", - "height", - ]); - }); - - it("collects refs from form parameters and parameter controls", () => { - const data = minimalAppBuilder([ - { - name: AppBuilderContainerNameType.Left, - widgets: [ - { - type: "form", - props: { - parameters: [{name: "form-param"}], - controls: [ - { - type: "parameter", - props: {name: "control-param"}, - }, - {type: "export", props: {name: "some-export"}}, - ], - }, - }, - ], - }, - ]); - - expect(getUiParameterRefs(data).map((ref) => ref.name)).toEqual([ - "form-param", - "control-param", - ]); - }); - - it("collects refs from controls widgets", () => { - const data = minimalAppBuilder([ - { - name: AppBuilderContainerNameType.Right, - widgets: [ - { - type: "controls", - props: { - controls: [ - { - type: "parameter", - props: { - name: "slider", - sessionId: "session-a", - }, - }, - ], - }, - }, - ], - }, - ]); - - expect(getUiParameterRefs(data)).toEqual([ - {name: "slider", sessionId: "session-a"}, - ]); - }); - - it("collects refs from widgets nested in stackUi", () => { - const data = minimalAppBuilder([ - { - name: AppBuilderContainerNameType.Left, - widgets: [ - { - type: "stackUi", - props: { - name: "Options", - widgets: [ - { - type: "controls", - props: { - controls: [ - { - type: "parameter", - props: {name: "nested-slider"}, - }, - ], - }, - }, - ], - }, - }, - ], - }, - ]); - - expect(getUiParameterRefs(data).map((ref) => ref.name)).toEqual([ - "nested-slider", - ]); - }); - - it("collects refs from widgets nested in accordionUi", () => { - const data = minimalAppBuilder([ - { - name: AppBuilderContainerNameType.Left, - widgets: [ - { - type: "accordionUi", - props: { - items: [ - { - name: "Section", - widgets: [ - { - type: "accordion", - props: { - parameters: [{name: "depth"}], - }, - }, - ], - }, - ], - }, - }, - ], - }, - ]); - - expect(getUiParameterRefs(data).map((ref) => ref.name)).toEqual([ - "depth", - ]); - }); -}); diff --git a/features/appbuilder/lib/appbuilder.ts b/features/appbuilder/lib/appbuilder.ts index 54a4a414..9855dc26 100644 --- a/features/appbuilder/lib/appbuilder.ts +++ b/features/appbuilder/lib/appbuilder.ts @@ -1,133 +1,33 @@ import { - AppBuilderWidgetType, IAppBuilder, - IAppBuilderControl, IAppBuilderParameterRef, - IAppBuilderWidget, - isAccordionUiWidget, isAccordionWidget, - isControlsWidget, - isFormWidget, - isParameterRefControl, - isStackUiWidget, } from "../config/appbuilder"; /** - * Maps parameter-type controls to {@link IAppBuilderParameterRef} entries. - * Export, action, and output controls are ignored. - * - * @param controls - Control list from a form or controls widget. - * @returns Parameter refs derived from `type: "parameter"` controls. + * Given an App Builder data object, return all parameter references + * occuring in any of the accordion widgets. + * @param data + * @returns */ -function parameterRefsFromControls( - controls: IAppBuilderControl[] | undefined, -): IAppBuilderParameterRef[] { - if (!controls) { - return []; - } - - return controls.filter(isParameterRefControl).map((control) => ({ - name: control.props.name, - sessionId: control.props.sessionId, - overrides: control.props.overrides, - disableIfDirty: control.props.disableIfDirty, - acceptRejectMode: control.props.acceptRejectMode, - })); -} - -/** - * Collects parameter references declared by a single widget. - * - * Uses an exhaustive `switch` on widget `type` so new widget types - * must be handled here (or explicitly grouped as non-parameter-bearing). - * - * Container widgets (`accordionUi`, `stackUi`) recurse into nested children. - */ -function parameterRefsFromWidget( - widget: IAppBuilderWidget, -): IAppBuilderParameterRef[] { - switch (widget.type) { - case AppBuilderWidgetType.Accordion: - return isAccordionWidget(widget) && widget.props.parameters - ? [...widget.props.parameters] - : []; - case AppBuilderWidgetType.Form: { - if (!isFormWidget(widget)) { - return []; - } - const refs: IAppBuilderParameterRef[] = []; - if (widget.props.parameters) { - refs.push(...widget.props.parameters); - } - refs.push(...parameterRefsFromControls(widget.props.controls)); - return refs; - } - case AppBuilderWidgetType.Controls: - return isControlsWidget(widget) - ? parameterRefsFromControls(widget.props.controls) - : []; - case AppBuilderWidgetType.AccordionUi: - return isAccordionUiWidget(widget) - ? widget.props.items.flatMap((item) => - item.widgets.flatMap(parameterRefsFromWidget), - ) - : []; - case AppBuilderWidgetType.StackUi: - return isStackUiWidget(widget) - ? widget.props.widgets.flatMap(parameterRefsFromWidget) - : []; - case AppBuilderWidgetType.Text: - case AppBuilderWidgetType.Image: - case AppBuilderWidgetType.RoundChart: - case AppBuilderWidgetType.LineChart: - case AppBuilderWidgetType.AreaChart: - case AppBuilderWidgetType.BarChart: - case AppBuilderWidgetType.Actions: - case AppBuilderWidgetType.AttributeVisualization: - case AppBuilderWidgetType.Agent: - case AppBuilderWidgetType.Progress: - case AppBuilderWidgetType.DesktopClientSelection: - case AppBuilderWidgetType.DesktopClientOutputs: - case AppBuilderWidgetType.SavedStates: - case AppBuilderWidgetType.SceneTreeExplorer: - case AppBuilderWidgetType.Table: - return []; - default: { - const unhandledType: never = widget.type; - throw new Error(`Unhandled widget type: ${String(unhandledType)}`); - } - } -} - -/** - * Returns all parameter references placed in the App Builder UI layout. - * - * Walks every container and tab, collecting refs from accordion, form, controls, - * and nested stack/accordion-ui widgets. Used to determine which session parameters are shown in - * the configurator (e.g. in-app agent context). - * - * @param data - Parsed App Builder settings / layout tree. - * @returns Refs ordered by first appearance in the layout (duplicates preserved if a parameter is placed in multiple widgets). - */ -export function getUiParameterRefs( - data: IAppBuilder, -): IAppBuilderParameterRef[] { - const refs: IAppBuilderParameterRef[] = []; - - for (const container of data.containers) { +export function getParameterRefs(data: IAppBuilder): IAppBuilderParameterRef[] { + return data.containers.reduce((acc, container) => { if (container.widgets) { - for (const widget of container.widgets) { - refs.push(...parameterRefsFromWidget(widget)); - } + container.widgets.forEach((widget) => { + if (isAccordionWidget(widget) && widget.props.parameters) { + acc.push(...widget.props.parameters); + } + }); } if (container.tabs) { - for (const tab of container.tabs) { - for (const widget of tab.widgets) { - refs.push(...parameterRefsFromWidget(widget)); - } - } + container.tabs.forEach((tab) => { + tab.widgets.forEach((widget) => { + if (isAccordionWidget(widget) && widget.props.parameters) { + acc.push(...widget.props.parameters); + } + }); + }); } - } - - return refs; + return acc; + }, [] as IAppBuilderParameterRef[]); } diff --git a/features/appbuilder/model/useAgent.ts b/features/appbuilder/model/useAgent.ts index 29707ddf..dd8ce9ae 100644 --- a/features/appbuilder/model/useAgent.ts +++ b/features/appbuilder/model/useAgent.ts @@ -3,7 +3,7 @@ import type {IShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter import {useAllParametersStateless} from "@AppBuilderLib/entities/parameter/model/useAllParametersStateless"; import {useShapeDiverStoreParameters} from "@AppBuilderLib/entities/parameter/model/useShapeDiverStoreParameters"; import {AppBuilderDataContext} from "@AppBuilderLib/features/appbuilder/lib/AppBuilderContext"; -import {getUiParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; +import {getParameterRefs} from "@AppBuilderLib/features/appbuilder/lib/appbuilder"; import {useNotificationStore} from "@AppBuilderLib/features/notifications/model/useNotificationStore"; import { composeSdColor, @@ -415,7 +415,7 @@ export function useAgent(props: Props) { // get App Builder data, we need it to extract parameter tooltips const {data: appBuilderData} = useContext(AppBuilderDataContext); const parameterRefs = useMemo( - () => (appBuilderData ? getUiParameterRefs(appBuilderData) : []), + () => (appBuilderData ? getParameterRefs(appBuilderData) : []), [appBuilderData], ); From 00a25ac389bf49037bf8b0841735838266070b55 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Fri, 17 Jul 2026 11:24:58 +0300 Subject: [PATCH 18/20] SS-9745: Get rid the code --- .../model-state/config/importModelState.ts | 10 +++----- .../config/importModelState.zod.ts | 2 -- features/webmcp/config/createModelState.ts | 25 ------------------- features/webmcp/config/importModelState.ts | 24 ++---------------- .../webmcp/config/listParameterDefinitions.ts | 17 +++---------- features/webmcp/config/setParameterValues.ts | 25 +++++-------------- .../webmcp/lib/parameterDefinitionMapper.ts | 2 -- 7 files changed, 15 insertions(+), 90 deletions(-) diff --git a/features/model-state/config/importModelState.ts b/features/model-state/config/importModelState.ts index bbd4ff0e..a8f48ba6 100644 --- a/features/model-state/config/importModelState.ts +++ b/features/model-state/config/importModelState.ts @@ -2,7 +2,7 @@ import {ResGetModelState} from "@shapediver/sdk.geometry-api-sdk-v2"; import type {z} from "zod"; import { importModelStateDataSchema, - importModelStateInvalidParameterSchema, + nameMessageSchema, } from "./importModelState.zod"; /** @@ -10,9 +10,7 @@ import { */ export type IImportModelStateData = z.infer; -export type IImportModelStateInvalidParameter = z.infer< - typeof importModelStateInvalidParameterSchema ->; +type NameMessage = z.infer; /** * Data returned from the useImportModelState hook. @@ -21,10 +19,10 @@ export type IImportModelStateResult = | { success: false; message: string; - invalidParameters?: IImportModelStateInvalidParameter[]; + invalidParameters?: NameMessage[]; } | { success: true; data: ResGetModelState; - invalidParameters?: IImportModelStateInvalidParameter[]; + invalidParameters?: NameMessage[]; }; diff --git a/features/model-state/config/importModelState.zod.ts b/features/model-state/config/importModelState.zod.ts index 92de9deb..28396bcc 100644 --- a/features/model-state/config/importModelState.zod.ts +++ b/features/model-state/config/importModelState.zod.ts @@ -13,5 +13,3 @@ export const importModelStateDataSchema = z.strictObject({ "modelStateId from create_model_state, or a full model view URL containing modelStateId.", ), }); - -export const importModelStateInvalidParameterSchema = nameMessageSchema; diff --git a/features/webmcp/config/createModelState.ts b/features/webmcp/config/createModelState.ts index 12c3740e..89b4a6d9 100644 --- a/features/webmcp/config/createModelState.ts +++ b/features/webmcp/config/createModelState.ts @@ -1,31 +1,6 @@ import {createModelStateDataSchema} from "@AppBuilderLib/features/model-state/config/createModelState.zod"; -import {z} from "zod"; /** WebMCP input = hook data without `image` (agents don't set export screenshot refs). */ export const createModelStateInputSchema = createModelStateDataSchema.omit({ image: true, }); - -export const createModelStateSuccessOutputSchema = z.object({ - success: z.literal(true), - modelStateId: z.string(), - modelStateImageUrl: z.string().optional(), - modelStateGltfUrl: z.string().optional(), - modelStateUsdzUrl: z.string().optional(), - modelViewUrl: z.string(), -}); - -export const createModelStateFailureOutputSchema = z.object({ - success: z.literal(false), - error: z.string(), -}); - -export const createModelStateOutputSchema = z.union([ - createModelStateSuccessOutputSchema, - createModelStateFailureOutputSchema, -]); - -export type CreateModelStateInput = z.infer; -export type CreateModelStateOutput = z.infer< - typeof createModelStateOutputSchema ->; diff --git a/features/webmcp/config/importModelState.ts b/features/webmcp/config/importModelState.ts index a5ae3fca..632c16ad 100644 --- a/features/webmcp/config/importModelState.ts +++ b/features/webmcp/config/importModelState.ts @@ -1,6 +1,6 @@ import { importModelStateDataSchema, - importModelStateInvalidParameterSchema, + nameMessageSchema, } from "@AppBuilderLib/features/model-state/config/importModelState.zod"; import {z} from "zod"; @@ -9,25 +9,5 @@ export const importModelStateInputSchema = importModelStateDataSchema; export const importModelStateSuccessOutputSchema = z.object({ success: z.literal(true), appliedParameterIds: z.array(z.string()), - invalidParameters: z - .array(importModelStateInvalidParameterSchema) - .optional(), + invalidParameters: z.array(nameMessageSchema).optional(), }); - -export const importModelStateFailureOutputSchema = z.object({ - success: z.literal(false), - message: z.string(), - invalidParameters: z - .array(importModelStateInvalidParameterSchema) - .optional(), -}); - -export const importModelStateOutputSchema = z.union([ - importModelStateSuccessOutputSchema, - importModelStateFailureOutputSchema, -]); - -export type ImportModelStateInput = z.infer; -export type ImportModelStateOutput = z.infer< - typeof importModelStateOutputSchema ->; diff --git a/features/webmcp/config/listParameterDefinitions.ts b/features/webmcp/config/listParameterDefinitions.ts index 22e88746..e1d6bcee 100644 --- a/features/webmcp/config/listParameterDefinitions.ts +++ b/features/webmcp/config/listParameterDefinitions.ts @@ -2,7 +2,7 @@ import {nameMessageSchema} from "@AppBuilderLib/features/model-state/config/impo import {ResParameterType} from "@shapediver/sdk.geometry-api-sdk-v2"; import {z} from "zod"; -export const colorValueSchema = z.object({ +const colorValueSchema = z.object({ red: z.number(), green: z.number(), blue: z.number(), @@ -16,7 +16,7 @@ export const parameterValueSchema = z.union([ colorValueSchema, ]); -export const ListParameterDefinitionItemSchema = z.object({ +const ListParameterDefinitionItemSchema = z.object({ id: z.string(), name: z.string(), displayname: z.string().optional(), @@ -46,25 +46,14 @@ export const listParameterDefinitionsInputSchema = z.strictObject({ .describe("Optional session namespace. Omit for the main model."), }); -export const listParameterDefinitionsErrorSchema = nameMessageSchema; - export const listParameterDefinitionsOutputSchema = z.object({ parameters: z.array(ListParameterDefinitionItemSchema), - errors: z.array(listParameterDefinitionsErrorSchema).optional(), + errors: z.array(nameMessageSchema).optional(), }); export type ListParameterDefinitionItem = z.infer< typeof ListParameterDefinitionItemSchema >; -export type ListParameterDefinitionsInput = z.infer< - typeof listParameterDefinitionsInputSchema ->; -export type ListParameterDefinitionsOutput = z.infer< - typeof listParameterDefinitionsOutputSchema ->; -export type ListParameterDefinitionsError = z.infer< - typeof listParameterDefinitionsErrorSchema ->; /** Supported types of parameters (for now). */ // TODO SS-9745: Grasshopper-dev-controlled exposure for additional parameter types. diff --git a/features/webmcp/config/setParameterValues.ts b/features/webmcp/config/setParameterValues.ts index ab2b43a9..b797bb98 100644 --- a/features/webmcp/config/setParameterValues.ts +++ b/features/webmcp/config/setParameterValues.ts @@ -1,6 +1,5 @@ import {nameMessageSchema} from "@AppBuilderLib/features/model-state/config/importModelState.zod"; import {z} from "zod"; -import type {ParameterValueInput} from "../lib/setParameterValueValidators/types"; import {parameterValueSchema} from "./listParameterDefinitions"; const setParameterUpdateSchema = z.strictObject({ @@ -18,8 +17,6 @@ const setParameterUpdateSchema = z.strictObject({ ), }); -export const setParameterValuesErrorSchema = nameMessageSchema; - export const setParameterValuesInputSchema = z.strictObject({ updates: z .array(setParameterUpdateSchema) @@ -28,19 +25,9 @@ export const setParameterValuesInputSchema = z.strictObject({ ), }); -export const setParameterValuesOutputSchema = z.object({ - applied: z.array(z.string()), - errors: z.array(setParameterValuesErrorSchema), -}); - -export type SetParameterValuesInput = z.infer< - typeof setParameterValuesInputSchema ->; -export type SetParameterValuesOutput = z.infer< - typeof setParameterValuesOutputSchema ->; -export type SetParameterValuesError = z.infer< - typeof setParameterValuesErrorSchema ->; -export type ParameterUpdateInput = SetParameterValuesInput["updates"][number]; -export type {ParameterValueInput}; +export type ParameterUpdateInput = z.infer; +export type SetParameterValuesError = z.infer; +export type SetParameterValuesOutput = { + applied: string[]; + errors: SetParameterValuesError[]; +}; diff --git a/features/webmcp/lib/parameterDefinitionMapper.ts b/features/webmcp/lib/parameterDefinitionMapper.ts index b4237fe4..dd3526af 100644 --- a/features/webmcp/lib/parameterDefinitionMapper.ts +++ b/features/webmcp/lib/parameterDefinitionMapper.ts @@ -7,8 +7,6 @@ import { } from "../config/listParameterDefinitions"; import {parseStringListIndex} from "./stringListValue"; -export type {ListParameterDefinitionItem}; - export function mapParameterDefinition( param: IShapeDiverParameter, ): ListParameterDefinitionItem { From 614db05ef8a4c66bac3f399e7cb313ff092c16e7 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Wed, 22 Jul 2026 10:16:49 +0300 Subject: [PATCH 19/20] SS-9745: Get rid excess mapping --- features/webmcp/lib/parameterDefinitionMapper.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/features/webmcp/lib/parameterDefinitionMapper.ts b/features/webmcp/lib/parameterDefinitionMapper.ts index dd3526af..b459ecdc 100644 --- a/features/webmcp/lib/parameterDefinitionMapper.ts +++ b/features/webmcp/lib/parameterDefinitionMapper.ts @@ -25,10 +25,6 @@ export function mapParameterDefinition( item.hidden = def.hidden; } - if (def.displayname && def.displayname !== name) { - item.displayname = def.displayname; - } - if (def.group?.name) { item.group = def.group.name; } From 05c4c97336ddc2f203f560b00392a789d54762d1 Mon Sep 17 00:00:00 2001 From: Dmitry Rumyantsev Date: Wed, 22 Jul 2026 10:40:24 +0300 Subject: [PATCH 20/20] SS-9745: Add evals test --- .../evals/__tests__/webmcpEvals.test.ts | 14 + features/webmcp/evals/agent_scenarios.md | 25 +- .../webmcp/evals/runWebmcpEvalScenarios.ts | 243 +++++++++++++++++ features/webmcp/evals/runWebmcpEvals.ts | 257 +----------------- 4 files changed, 284 insertions(+), 255 deletions(-) create mode 100644 features/webmcp/evals/__tests__/webmcpEvals.test.ts create mode 100644 features/webmcp/evals/runWebmcpEvalScenarios.ts 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 index 8ffab8c3..2ad29b22 100644 --- a/features/webmcp/evals/agent_scenarios.md +++ b/features/webmcp/evals/agent_scenarios.md @@ -6,16 +6,24 @@ Weak-model stress tests. Run against `http://localhost:3000/?g=SS-8076.json` via 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. Call tools: `const r = await mc.executeTool("tool_name", JSON.stringify(input));` +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. -4. `await mc.getTools()` lists registered tools (4 expected). +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. -- "Width" = the INT/float width param; "Material" = StringList; "Paint" = Color; "Color" = a StringList named Color (trap). +- On SS-8076, **Color** = StringList trap (label vs index). Width/Material/Paint apply only on other models. - Indices are 0-based integers. ## Scenarios @@ -153,3 +161,14 @@ For each scenario return: - 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/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 index 75eedb27..bfa827a2 100644 --- a/features/webmcp/evals/runWebmcpEvals.ts +++ b/features/webmcp/evals/runWebmcpEvals.ts @@ -1,261 +1,14 @@ -/// -import {readFileSync} from "fs"; -import {dirname, join} from "path"; -import {fileURLToPath} from "url"; -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"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -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; -} - -interface EvalScenario { - id: string; - tool: string; - description: string; - input: Record; - expect: EvalExpect; -} - -function loadScenarios(): EvalScenario[] { - const raw = readFileSync(join(__dirname, "evals.json"), "utf8"); - - return JSON.parse(raw) 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); - } - - if (expect.success === true) { - console.log(` schema-valid (mocked execution)`); - } - - return null; -} - -async function runScenario(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}"`; - } -} + loadWebmcpEvalScenarios, + runWebmcpEvalScenario, +} from "./runWebmcpEvalScenarios"; async function main() { - const scenarios = loadScenarios(); + const scenarios = loadWebmcpEvalScenarios(); let failed = 0; for (const scenario of scenarios) { - const reason = await runScenario(scenario); + const reason = await runWebmcpEvalScenario(scenario); if (reason) { console.log(`[FAIL] ${scenario.id}: ${reason}`);