Add explicit MCP form logic tool

This commit is contained in:
Zachariah K. Sharma
2026-06-13 17:20:35 -06:00
parent 0a02fbfd28
commit b732e18cb1
2 changed files with 137 additions and 3 deletions
+60
View File
@@ -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");
});
});
+77 -3
View File
@@ -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<string, (ctx: Ctx, args: Record<string, unknown>) => Promise<string>> = {
async list_forms(ctx, args) {
const isAdmin = ctx.role === "admin";
@@ -421,6 +484,17 @@ const TOOLS: Record<string, (ctx: Ctx, args: Record<string, unknown>) => 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");