Add explicit MCP form logic tool
This commit is contained in:
@@ -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
@@ -8,7 +8,7 @@ import { visibleFields } from "./logic";
|
|||||||
import { recordAudit } from "./audit";
|
import { recordAudit } from "./audit";
|
||||||
import { snapshotForm } from "./versions";
|
import { snapshotForm } from "./versions";
|
||||||
import { publicFormUrl } from "./urls";
|
import { publicFormUrl } from "./urls";
|
||||||
import type { Field, FormSettings } from "./types";
|
import type { Field, FormSettings, LogicGroup, LogicOp } from "./types";
|
||||||
|
|
||||||
const PROTOCOL_VERSION = "2024-11-05";
|
const PROTOCOL_VERSION = "2024-11-05";
|
||||||
|
|
||||||
@@ -77,6 +77,32 @@ function err(id: JsonRpcRes["id"], code: number, message: string): JsonRpcRes {
|
|||||||
// ---------- Tool definitions ----------
|
// ---------- 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 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 = {
|
const FIELD_INPUT_SCHEMA = {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -90,11 +116,12 @@ const FIELD_INPUT_SCHEMA = {
|
|||||||
min: { type: "number" }, max: { type: "number" },
|
min: { type: "number" }, max: { type: "number" },
|
||||||
accept: { type: "string" }, maxSizeMB: { type: "number" },
|
accept: { type: "string" }, maxSizeMB: { type: "number" },
|
||||||
expression: { type: "string" },
|
expression: { type: "string" },
|
||||||
|
showIf: LOGIC_GROUPS_SCHEMA,
|
||||||
},
|
},
|
||||||
required: ["type","label"],
|
required: ["type","label"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOOL_LIST = [
|
export const MCP_TOOL_LIST = [
|
||||||
{
|
{
|
||||||
name: "list_forms",
|
name: "list_forms",
|
||||||
description: "List forms visible to the authenticated user. Paginated.",
|
description: "List forms visible to the authenticated user. Paginated.",
|
||||||
@@ -175,7 +202,7 @@ const TOOL_LIST = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_field",
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -186,6 +213,20 @@ const TOOL_LIST = [
|
|||||||
additionalProperties: false,
|
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",
|
name: "reorder_fields",
|
||||||
description: "Set the order of fields. Pass an array of field ids.",
|
description: "Set the order of fields. Pass an array of field ids.",
|
||||||
@@ -255,6 +296,28 @@ const TOOL_LIST = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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>> = {
|
const TOOLS: Record<string, (ctx: Ctx, args: Record<string, unknown>) => Promise<string>> = {
|
||||||
async list_forms(ctx, args) {
|
async list_forms(ctx, args) {
|
||||||
const isAdmin = ctx.role === "admin";
|
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);
|
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) {
|
async reorder_fields(ctx, args) {
|
||||||
const formId = String(args.formId);
|
const formId = String(args.formId);
|
||||||
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
|
||||||
|
|||||||
Reference in New Issue
Block a user