From b732e18cb17a9cd3c7a87c0cf553204d5b260484 Mon Sep 17 00:00:00 2001 From: "Zachariah K. Sharma" Date: Sat, 13 Jun 2026 17:20:35 -0600 Subject: [PATCH] Add explicit MCP form logic tool --- src/lib/mcp.test.ts | 60 ++++++++++++++++++++++++++++++++++ src/lib/mcp.ts | 80 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/lib/mcp.test.ts diff --git a/src/lib/mcp.test.ts b/src/lib/mcp.test.ts new file mode 100644 index 0000000..48a8d5d --- /dev/null +++ b/src/lib/mcp.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { applyFieldLogic, MCP_TOOL_LIST } from "./mcp"; +import type { Field, LogicGroup } from "./types"; + +const fields: Field[] = [ + { id: "source", type: "select", label: "Source", options: [{ value: "yes", label: "Yes" }] }, + { id: "target", type: "short_text", label: "Target", required: true }, +]; + +describe("MCP conditional logic discovery", () => { + it("advertises a dedicated set_field_logic tool with documented AND/OR behavior", () => { + const tool = MCP_TOOL_LIST.find((item) => item.name === "set_field_logic"); + + expect(tool?.description).toContain("AND"); + expect(tool?.description).toContain("OR"); + expect(tool?.inputSchema.properties.groups).toMatchObject({ + type: "array", + }); + }); + + it("documents showIf when agents create or add fields", () => { + const addField = MCP_TOOL_LIST.find((item) => item.name === "add_field"); + const fieldSchema = addField?.inputSchema.properties.field; + + expect(fieldSchema).toMatchObject({ + properties: { + showIf: { + type: "array", + }, + }, + }); + }); +}); + +describe("applyFieldLogic", () => { + it("sets logic on only the requested field", () => { + const groups: LogicGroup[] = [{ rules: [{ fieldId: "source", op: "eq", value: "yes" }] }]; + + expect(applyFieldLogic(fields, "target", groups)).toEqual([ + fields[0], + { ...fields[1], showIf: groups }, + ]); + }); + + it("clears logic when groups are empty", () => { + const withLogic: Field[] = [ + fields[0], + { ...fields[1], showIf: [{ rules: [{ fieldId: "source", op: "eq", value: "yes" }] }] }, + ]; + + expect(applyFieldLogic(withLogic, "target", [])).toEqual(fields); + }); + + it("rejects missing target and source fields", () => { + const groups: LogicGroup[] = [{ rules: [{ fieldId: "missing", op: "eq", value: "yes" }] }]; + + expect(() => applyFieldLogic(fields, "missing-target", [])).toThrow("Target field not found"); + expect(() => applyFieldLogic(fields, "target", groups)).toThrow("Condition field not found: missing"); + }); +}); diff --git a/src/lib/mcp.ts b/src/lib/mcp.ts index 22b7241..1f4143e 100644 --- a/src/lib/mcp.ts +++ b/src/lib/mcp.ts @@ -8,7 +8,7 @@ import { visibleFields } from "./logic"; import { recordAudit } from "./audit"; import { snapshotForm } from "./versions"; import { publicFormUrl } from "./urls"; -import type { Field, FormSettings } from "./types"; +import type { Field, FormSettings, LogicGroup, LogicOp } from "./types"; const PROTOCOL_VERSION = "2024-11-05"; @@ -77,6 +77,32 @@ function err(id: JsonRpcRes["id"], code: number, message: string): JsonRpcRes { // ---------- Tool definitions ---------- const ALL_FIELD_TYPES = ["short_text","long_text","number","email","date","select","multi_select","checkbox","rating","file","signature","page_break","calculated"] as const; +const LOGIC_OPS: LogicOp[] = ["eq","neq","contains","gt","lt","empty","not_empty"]; + +const LOGIC_RULE_SCHEMA = { + type: "object", + description: "A visibility condition referencing another field by id.", + properties: { + fieldId: { type: "string", description: "Id of the field whose answer controls visibility." }, + op: { type: "string", enum: LOGIC_OPS }, + value: { description: "Comparison value. Omit for empty and not_empty." }, + }, + required: ["fieldId", "op"], + additionalProperties: false, +}; + +const LOGIC_GROUPS_SCHEMA = { + type: "array", + description: "Visibility groups. Rules within a group are ANDed; groups are ORed. Pass [] to clear conditional logic.", + items: { + type: "object", + properties: { + rules: { type: "array", items: LOGIC_RULE_SCHEMA, minItems: 1 }, + }, + required: ["rules"], + additionalProperties: false, + }, +}; const FIELD_INPUT_SCHEMA = { type: "object", @@ -90,11 +116,12 @@ const FIELD_INPUT_SCHEMA = { min: { type: "number" }, max: { type: "number" }, accept: { type: "string" }, maxSizeMB: { type: "number" }, expression: { type: "string" }, + showIf: LOGIC_GROUPS_SCHEMA, }, required: ["type","label"], }; -const TOOL_LIST = [ +export const MCP_TOOL_LIST = [ { name: "list_forms", description: "List forms visible to the authenticated user. Paginated.", @@ -175,7 +202,7 @@ const TOOL_LIST = [ }, { name: "update_field", - description: "Update properties of an existing field.", + description: "Update properties of an existing field. Prefer set_field_logic for conditional visibility.", inputSchema: { type: "object", properties: { @@ -186,6 +213,20 @@ const TOOL_LIST = [ additionalProperties: false, }, }, + { + name: "set_field_logic", + description: "Set conditional visibility for a field. Rules within each group are ANDed; groups are ORed. Pass an empty groups array to clear logic.", + inputSchema: { + type: "object", + properties: { + formId: { type: "string" }, + fieldId: { type: "string", description: "Target field to show or hide." }, + groups: LOGIC_GROUPS_SCHEMA, + }, + required: ["formId", "fieldId", "groups"], + additionalProperties: false, + }, + }, { name: "reorder_fields", description: "Set the order of fields. Pass an array of field ids.", @@ -255,6 +296,28 @@ const TOOL_LIST = [ }, ] as const; +const TOOL_LIST = MCP_TOOL_LIST; + +export function applyFieldLogic(fields: Field[], fieldId: string, groups: LogicGroup[]): Field[] { + if (!fields.some((field) => field.id === fieldId)) throw new Error("Target field not found"); + const fieldIds = new Set(fields.map((field) => field.id)); + for (const group of groups) { + if (!group.rules.length) throw new Error("Logic groups must contain at least one rule"); + for (const rule of group.rules) { + if (!fieldIds.has(rule.fieldId)) throw new Error(`Condition field not found: ${rule.fieldId}`); + if (rule.fieldId === fieldId) throw new Error("A field cannot depend on itself"); + if (!LOGIC_OPS.includes(rule.op)) throw new Error(`Unsupported logic operator: ${rule.op}`); + } + } + return fields.map((field) => { + if (field.id !== fieldId) return field; + const next = { ...field }; + if (groups.length) next.showIf = groups; + else delete next.showIf; + return next; + }); +} + const TOOLS: Record) => Promise> = { async list_forms(ctx, args) { const isAdmin = ctx.role === "admin"; @@ -421,6 +484,17 @@ const TOOLS: Record) => Promise return JSON.stringify({ ok: true }, null, 2); }, + async set_field_logic(ctx, args) { + const formId = String(args.formId); + const fieldId = String(args.fieldId); + if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden"); + const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } }); + const groups = Array.isArray(args.groups) ? args.groups as LogicGroup[] : []; + const fields = applyFieldLogic(parseFields(form.fields), fieldId, groups); + await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } }); + return JSON.stringify({ ok: true, groups: groups.length }, null, 2); + }, + async reorder_fields(ctx, args) { const formId = String(args.formId); if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");