diff --git a/.changeset/slimy-zoos-build.md b/.changeset/slimy-zoos-build.md new file mode 100644 index 0000000..79c036f --- /dev/null +++ b/.changeset/slimy-zoos-build.md @@ -0,0 +1,6 @@ +--- +'@formio/mcp': patch +'@formio/ai': patch +--- + +FIO–11561: Add form revision MCP tools diff --git a/openspec/changes/add-form-revisions-mcp-tools/.openspec.yaml b/openspec/changes/add-form-revisions-mcp-tools/.openspec.yaml new file mode 100644 index 0000000..a5d4224 --- /dev/null +++ b/openspec/changes/add-form-revisions-mcp-tools/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-tdd +created: 2026-05-04 diff --git a/openspec/changes/add-form-revisions-mcp-tools/design.md b/openspec/changes/add-form-revisions-mcp-tools/design.md new file mode 100644 index 0000000..b9b728f --- /dev/null +++ b/openspec/changes/add-form-revisions-mcp-tools/design.md @@ -0,0 +1,175 @@ +## Context + +The Form.io revision API is documented in [plugin/skills/formio-api/references/project-form-revisions.md](plugin/skills/formio-api/references/project-form-revisions.md): list (`GET /form/:formId/v`), fetch by version (`GET /form/:formId/v/:version`), enable revisions (a regular `PUT /form/:formId` with `revisions: true` in the body), draft save/get (`PUT|GET /form/:formId/draft`), and publish (`PUT /form/:formId` once revisions are enabled). Auth is the same `x-jwt-token` portal-login flow used by every other tool. + +The MCP server already has a stable pattern for form-scoped tools — see [packages/mcp-server/src/tools/form_get.ts](packages/mcp-server/src/tools/form_get.ts), [form_list.ts](packages/mcp-server/src/tools/form_list.ts), [form_update.ts](packages/mcp-server/src/tools/form_update.ts): + +1. Take `cwd` plus a tool-specific input via Zod schemas; describe with `cwdSchema` so the project resolver applies. +2. Resolve the project config with `resolveProjectConfig(cwd, config)`. +3. Translate a `formIdOrPath` argument with `isMongoId` so callers can use either a MongoDB id or a path. +4. Call `formioFetch(path, params, cfg, init)` for HTTP I/O. +5. Wrap the response with `toMcpTextResult` / errors with `toMcpError`. +6. Register from `tools/index.ts` inside `registerAllTools`. + +The existing `form_*` tools all assume the canonical form path is `form/{id}` — the same prefix applies to revision endpoints, so reuse is straightforward. + +## Goals / Non-Goals + +**Goals:** + +- Cover the full FIO-11561 scope: list revisions, get form at a specific revision, enable revisions, create draft, publish draft. +- Match existing tool ergonomics so an LLM can use them with the same mental model as `form_get` / `form_update` (accept either `_id` or `path` for the form, accept either sequential `vid` or revision `_id` for `form_revision_get`, return JSON via `toMcpTextResult`). +- Keep the diff small: no new dependencies, no new helpers unless reused by ≥2 tools, no parallel "revisions service" abstraction. +- Update the skill reference's `## MCP Tool Preference` section so Claude prefers the new tools over raw HTTP for revision operations. + +**Non-Goals:** + +- Diffing two revisions, restoring a revision in-place, or any client-side merge logic. Callers can compose `form_revision_get` + `form_update` themselves. +- Exposing revision history for resources or actions (those entities aren't revisioned). +- Adding a `form_revisions_disable` tool; un-setting `revisions` is a regular `form_update` and out of ticket scope. +- Caching revision payloads. +- Changing the MCP server's auth model. + +## Decisions + +### Decision 1: Five tools, one per ticket bullet + +Map the ticket's five bullets directly to five tools rather than collapsing into a single multi-action tool: + +- `form_revisions_list` +- `form_revision_get` +- `form_revisions_set` +- `form_draft_create` +- `form_draft_publish` + +**Rationale:** the existing tool registry already follows verb-per-tool naming (`form_get`, `form_update`, `form_create`). One tool per action keeps tool descriptions narrowly scoped, which matters for tool-selection accuracy by the calling LLM. Collapsing into `form_revisions` with a `mode` discriminator hurts that. + +**Alternatives considered:** + +- Single `form_revisions` tool with an `action` discriminator. Rejected — descriptions become a wall of conditionals, defeats the registry pattern. +- Three tools (list, get, write) with the write tool taking an enum of `enable|draft|publish`. Rejected — same issue at a smaller scale; also makes draft vs publish ambiguous since both are `PUT`. + +### Decision 2: Accept `formIdOrPath` everywhere, resolve to `_id` once + +Each new tool accepts a `formIdOrPath: string` arg and, when it is not a Mongo id, performs a single `formioFetch` to resolve the path to an `_id` before issuing the revision-specific request. Revision endpoints in the reference are documented under `/form/:formId/v` etc. with `formId` as the Mongo `_id`; the safest read is that the `_id` form is canonical. Resolving once on entry keeps callers from having to know which endpoints accept paths. + +**Rationale:** matches `form_get.ts`'s `isMongoId(formIdOrPath)` ergonomic. Avoids subtle 404s if the API rejects paths on `/v/:version`. + +**Alternatives considered:** + +- Pass through whatever the caller gave us. Rejected — burns the LLM if the API inconsistently accepts paths. +- Add a separate `formId` arg with no path support. Rejected — would diverge from `form_get`/`form_update` UX. + +### Decision 3: `form_revision_get` accepts either sequential `vid` or revision `_id` + +Per the reference, `GET /form/:formId/v/:version` accepts both. Expose a single `version: string | number` arg with a Zod union, document both forms in `.describe()`, and pass through as a string segment. + +**Rationale:** matches the API; no client-side branching needed. + +### Decision 4: `form_revisions_set` covers enable, switch, and disable via the form's `revisions` field + +The form doc carries a single string field `revisions` with three values: `""` (disabled), `"current"` (enabled, latest-revision rendering), `"original"` (enabled, captured-revision rendering). One tool covers all transitions. + +The tool takes a **required** `mode: "current" | "original" | ""` argument matching the wire values verbatim — no default. When the calling LLM invokes the tool without `mode`, the Zod schema rejects the call so the agent confirms intent with the user. The tool then: + +1. `GET /form/:formId` to fetch the current form definition. +2. Merge `revisions: mode` into the form body. Verified against `mcp.forms`: single string field, not a boolean, not nested. +3. `PUT /form/:formId` with the merged body. + +Short-circuits when the form's current `revisions` value already matches the requested `mode` (including `""` on an already-disabled form) to avoid unnecessary writes. + +**Rationale:** one tool with one knob matches the underlying schema (one field, three values). Splitting enable/disable across two tools would inflate the surface without gaining clarity. Disable is non-destructive — verified end-to-end that `revisions: ""` preserves `_vid` and existing `formrevisions` rows server-side. + +**Alternatives considered:** + +- Separate `form_revisions_set` + `form_revisions_disable`. Rejected — two tools for one wire field. Required-mode guard already forces the agent to be explicit; an explicit `mode: ""` is a safer disable path than a no-arg "disable" tool that could be invoked by mistake. +- Boolean-only. Rejected — drops the display-mode choice the portal exposes. +- `mode` with a default. Rejected — the three values have different effects, and silent defaults invite surprise. +- Require the caller to pass the full form body. Rejected — undermines the tool's value (LLM has to do the GET anyway). +- Use a hypothetical `PATCH` endpoint. Rejected — not present in the reference. + +### Decision 5: `form_draft_create` accepts either an explicit `definition` or "copy from current published", and an optional `note` + +Default: when called with no `definition`, the tool fetches the current published form and saves that as the draft (`PUT /form/:formId/draft`). When `definition` is supplied (full form JSON), it is used verbatim. The tool ALSO accepts an optional `note: string` that is forwarded as `_vnote` in the draft save body — drafts are themselves rows in `formrevisions` and carry their own `_vnote` (verified: a `Save Draft` click in the portal sends `_vnote` in the `PUT /form/:id/draft` body and that note persists on the `_vid: "draft"` row). + +The tool description must state explicitly that **a form has at most one active draft at a time** ([per the help docs](https://help.form.io/userguide/forms/form-revisions)). Calling `form_draft_create` overwrites any existing draft. Storage detail: drafts live in `formrevisions` with `_vid: "draft"` (string sentinel) — a single row per form. + +**Rationale:** matches the most common UX — "start a draft from the live form, then iterate" — without forcing the LLM to hand-roll a body. Supplying `definition` keeps the explicit path open. + +**Alternatives considered:** + +- Always require `definition`. Rejected — pushes the GET-then-PUT pattern onto every caller. +- Always copy from published, no override. Rejected — blocks the case where the LLM has constructed a new body from scratch. +- Refuse-if-draft-exists (require an explicit overwrite flag). Rejected — adds friction without protecting much, since the portal itself silently overwrites. + +### Decision 6: `form_draft_publish` reads draft, then publishes via `PUT /form/:formId`, and forwards a revision `note` + +Per the reference, publishing is a regular form `PUT` once revisions are enabled. Per the [help docs](https://help.form.io/userguide/forms/form-revisions), the portal captures a free-text "Revision notes" string at publish time and surfaces it on the Revisions tab. The MCP tool exposes the same as an optional `note: string`. The tool: + +1. `GET /form/:formId/draft` to fetch the current draft (skipped if `definition` is supplied). +2. Attach the `note` to the body as **`_vnote`** at top level. Confirmed end-to-end against the live server (Playwright capture): `Save Draft` in the portal sends `PUT /form/:id/draft` with `_vnote` in the request body, and `Publish` sends `PUT /form/:id` with `_vnote` in the request body. Server populates `_vuser` itself from the JWT; callers do NOT send it. +3. `PUT /form/:formId` with the merged body. +4. Return the published form (which carries the new `_vid`). + +Accept a `definition` arg (same shape as `form_draft_create`) to publish without going through the saved draft. + +**Important nuance** (verified): publish is a no-op when the body matches the current published form ignoring `_vnote`. No new revision row is created, the draft row survives, and the response returns the unchanged form. The MCP tool surfaces this as success — it does not fabricate a "no-op" error. Callers wanting a guaranteed `_vid` bump must change something in the body. + +**Auto-clear:** when publish DOES create a new revision row, the server also removes the `_vid: "draft"` row. The MCP tool does not need to delete the draft separately. + +**Rationale:** the ticket lists `note` as one of the four columns surfaced by "Show form revisions" — without a `note` arg on publish, the listing tool always shows blank notes for revisions the agent created. That defeats half the value of the listing. + +**Alternatives considered:** + +- No `note` arg, callers preset it on the draft body. Rejected — `note` is a publish-time input in the portal, not a draft body field. Wrong layer. +- Required `note`. Rejected — the portal allows blank notes; matching that keeps the tool unsurprising. + +### Decision 7: Reference doc edit instead of new reference doc + +The proposal initially called for a new `form-revisions.md` reference. The repo already has [plugin/skills/formio-api/references/project-form-revisions.md](plugin/skills/formio-api/references/project-form-revisions.md). Edit the existing file's `## MCP Tool Preference` section from: + +> No MCP tool covers this operation — use the HTTP endpoint directly. + +to a list of the five new tools mapped to the endpoints they cover, retaining a fallback note for endpoints they don't. + +**Rationale:** avoids reference duplication and validator churn. + +## Risks / Trade-offs + +- [Reading current form before enable risks a `_vid` race] → keep the `GET → mutate → PUT` window tight; surface 409s as `toMcpError` so the LLM can retry. No locking added; matches existing `form_update` posture. +- [Some Form.io deployments may not accept paths on `/v/:version`] → resolving to `_id` on entry side-steps this, at the cost of one extra GET when callers pass a path. Acceptable. +- [`form_draft_create`'s default-copy-from-published is a "magic" behavior] → document it explicitly in the tool description so the LLM knows when to pass `definition`. If telemetry shows confusion later, split into two tools (`form_draft_open_from_published`, `form_draft_save`). +- [Drift risk: reference doc says publishing is a `PUT /form/:formId`, which is the same endpoint as enable] → the tool boundary handles disambiguation (`form_revisions_set` vs `form_draft_publish`). Reference doc edit clarifies which tool to prefer. +- [Form Revisions are gated behind the Security Module / license per [help docs](https://help.form.io/userguide/forms/form-revisions); unlicensed projects will reject calls] → propagate the upstream `402`/`403` through `toMcpError` verbatim so the LLM surfaces a useful message instead of retrying blindly. +- [Exact JSON field names for `note`, `mode`, and the per-revision listing fields (`_vid` vs `vid`, `modified`, `owner`, `note`) are not pinned by the public reference] → confirm during the Red phase by exercising a real revisioned form via `formioFetch` and snapshot-test the response shape. Tool descriptions document only what was confirmed. +- [Single-active-draft constraint means `form_draft_create` silently overwrites] → call this out in the tool description; consider returning the *previous* draft body in the tool response so the agent can recover if it overwrote unintentionally. Defer to implementation; not in scope of this design unless ticket grows. + +## Migration Plan + +No migration. New tools, additive change. Rollback is `git revert`. + +## Open Questions + +Pinned against the live `mcp` MongoDB on 2026-05-04 — a freshly created revisioned form, its draft response, and its `formrevisions` doc together yield the following shape, which the implementation can target directly: + +- Enable mode is a **single string field** on the form doc: `revisions: "" | "current" | "original"` (empty string = disabled). No sibling display-mode field. The MCP tool's `mode` arg maps 1:1 onto this field. +- The draft endpoint returns the full form definition (same shape as a published form). The wire shape round-trips through the same JSON as published forms, but the storage is in `formrevisions` (NOT a separate `formdrafts` collection and NOT on the form doc itself). +- Drafts live as a **single row in `formrevisions` per form** with `_vid: "draft"` (string sentinel, not a number). Saving a draft upserts that row — there is at most one active draft per form. +- Each numeric `formrevisions` document carries: `_id`, `_rid` (parent form `_id`), `revisionId` (== `_id`), `_vid` (sequential integer), `_vnote` (note, empty string when none provided), `_vuser` (string display name of the publisher, e.g. `"admin"`), `modified` (timestamp), plus the full form snapshot fields (`title`, `components`, `access`, etc.). The `_vid: "draft"` row carries the same fields except `_vid` is the literal string `"draft"`. +- `_vuser` is populated server-side from the JWT — callers do NOT send it. +- Therefore the MCP `note` arg on both `form_draft_create` and `form_draft_publish` maps to wire field **`_vnote`** at the top level of the request body. The listing tool exposes `_vid`/`_vnote`/`_vuser`/`modified` directly without reshaping. + +Confirmed via Playwright + MongoDB on 2026-05-04 against a local portal session: + +- The publish payload IS `PUT /form/:id` with `_vnote` at the top level of the request body. Submitted body included `_vnote: ""` alongside the full form definition; response returned `_vid` incremented by 1, and the new `formrevisions` doc carried the submitted `_vnote` plus `_vuser` set to the publisher's display name. +- The response body to `PUT /form/:id` strips `_vnote` (not echoed back). Callers should rely on the side-effect of the new revision row, not on the response payload, to confirm the note was persisted. +- The publish request body also carries the *current* `_vid` (the version being published from). Server returns the new `_vid`. MCP tool should preserve whatever `_vid` is on the form/draft body it sends — no special handling needed. +- `_vid` stays at `0` while `revisions` is `""` — non-revisioned forms never bump it (verified against multiple forms in the same project). The counter only advances once revisions are turned on. +- **Enabling revisions on an existing form is a single write — the server seeds the first revision automatically.** A `PUT /form/:id` that flips `revisions` from `""` to `"current"` (or `"original"`) bumps `_vid` from `0` to `1` and inserts one `formrevisions` row at `_vid: 1`, capturing the form state at enable time (with empty `_vnote`, `_vuser` set to the publisher). There is no separate seed call. `form_revisions_set` therefore needs only the GET → merge → PUT it already plans; it must NOT issue any extra seed write. +- A `PUT /form/:id` on a revisioned form creates one new numeric `formrevisions` row **only when the submitted body actually differs from the current published form**. If the body is identical to the current published (ignoring `_vnote`), the server returns 200 with the existing form unchanged — no new revision, no error. This is a safe no-op. +- When `PUT /form/:id` does create a new numeric revision row, it also **removes the existing `_vid: "draft"` row** (verified end-to-end). When `PUT /form/:id` is a no-op (no diff), the draft row survives untouched. So publish auto-clears the draft only when a real revision is created. +- Publish does NOT auto-fetch the saved draft. The body sent in `PUT /form/:id` is what gets published. The portal UI fetches `GET /form/:id/draft` first, then sends that body to `PUT /form/:id`. + +Remaining unresolved: + +- `form_revisions_set` no-op behavior is now decided: short-circuit when the form already has revisions enabled with the requested `mode`; PUT to switch when the mode differs. Already captured in the spec; nothing left to confirm here. diff --git a/openspec/changes/add-form-revisions-mcp-tools/proposal.md b/openspec/changes/add-form-revisions-mcp-tools/proposal.md new file mode 100644 index 0000000..6421f6d --- /dev/null +++ b/openspec/changes/add-form-revisions-mcp-tools/proposal.md @@ -0,0 +1,41 @@ +## Why + +Form.io supports per-form revision history (versioned form definitions with author, timestamp, and note metadata) plus a draft/publish workflow, but the MCP server exposes none of it. Agents using the server cannot inspect prior form versions, restore one, enable revisions on an existing form, or move a draft to publication — forcing users out of the agent loop and back to the portal UI for any version-aware editing. Tracked in [FIO-11561](https://formio.atlassian.net/browse/FIO-11561). + +## What Changes + +- Add six new MCP tools under `packages/mcp-server/src/tools/`: + - `form_revisions_list` — return revision summaries (`vid`, modified date, owner display, note) for a form. + - `form_revision_get` — return the full form definition at a specific `vid`. + - `form_revisions_set` — set the form's `revisions` field to enable, switch, or disable revisions. Required `mode: "current" | "original" | ""` — no default. `"current"` and `"original"` enable (display mode for historical submissions); `""` disables (server preserves `_vid` and existing `formrevisions` rows). Calling without `mode` is a schema error so the agent asks the user. + - `form_draft_create` — create or overwrite the form's single active draft (the portal allows only one draft at a time). + - `form_draft_get` — fetch the form's current active draft (the `_vid: "draft"` row in `formrevisions`). Distinct from `form_revision_get`, which fetches a published, immutable numbered revision. + - `form_draft_publish` — promote the current draft (or a caller-supplied definition) to a new published revision, accepting an optional `note` so the published revision carries the same "Revision notes" string the portal UI captures. +- Register all six via `registerAllTools` in [packages/mcp-server/src/tools/index.ts](packages/mcp-server/src/tools/index.ts), following the existing `form_*` registration pattern (cwd-resolved project config, `formioFetch`, `toMcpTextResult` / `toMcpError`). +- Update the existing [plugin/skills/formio-api/references/project-form-revisions.md](plugin/skills/formio-api/references/project-form-revisions.md) so its `## MCP Tool Preference` section names the new tools instead of saying "No MCP tool covers this operation — use the HTTP endpoint directly." +- If needed, tighten the router skill's trigger clause in [plugin/skills/formio-api/SKILL.md](plugin/skills/formio-api/SKILL.md) so revision-related prompts (drafts, publishing, version history) reliably activate the skill. + +## Capabilities + +### New Capabilities + +- `form-revisions`: MCP tools and skill reference for listing form revisions, fetching a form at a specific revision, enabling revisions on a form, and managing the draft/publish lifecycle for a form. + +### Modified Capabilities + + + + +## Impact + +- **Code**: new files under `packages/mcp-server/src/tools/` (`form_revisions_list.ts`, `form_revision_get.ts`, `form_revisions_set.ts`, `form_draft_create.ts`, `form_draft_get.ts`, `form_draft_publish.ts`), edits to `tools/index.ts` to register them, new tests under `packages/mcp-server/src/tools/__tests__/` (or wherever the existing form tool tests live). +- **Skills library**: edit `plugin/skills/formio-api/references/project-form-revisions.md` (`## MCP Tool Preference` section); optional trigger-clause tightening in `plugin/skills/formio-api/SKILL.md`. +- **Validator**: no schema-level change expected — existing skills-validator coverage of `project-form-revisions.md` remains sufficient. +- **APIs consumed** (per `project-form-revisions.md`): + - `GET ${FORMIO_PROJECT_URL}/form/:formId/v` — list revisions + - `GET ${FORMIO_PROJECT_URL}/form/:formId/v/:version` — get form at specific revision (sequential `vid` or revision `_id`) + - `PUT ${FORMIO_PROJECT_URL}/form/:formId` — enable revisions (set `revisions` flag) and publish (a `PUT` on a revisioned form publishes the next revision) + - `PUT ${FORMIO_PROJECT_URL}/form/:formId/draft` — save a draft + - `GET ${FORMIO_PROJECT_URL}/form/:formId/draft` — read current draft (used to support an "open the draft" flow even though the ticket only names create/publish). +- **Auth**: no change — reuses existing portal-login JWT via `formioFetch`. +- **No breaking changes** to existing tools or skill structure. diff --git a/openspec/changes/add-form-revisions-mcp-tools/specs/form-revisions/spec.md b/openspec/changes/add-form-revisions-mcp-tools/specs/form-revisions/spec.md new file mode 100644 index 0000000..a4eff52 --- /dev/null +++ b/openspec/changes/add-form-revisions-mcp-tools/specs/form-revisions/spec.md @@ -0,0 +1,234 @@ +## ADDED Requirements + +### Requirement: List form revisions + +The MCP server SHALL expose a `form_revisions_list` tool that returns the revision history for a Form.io form, accepting either a form `_id` or a path and resolving paths to `_id` before issuing the upstream request. The tool SHALL return a **compact summary per revision** rather than the raw upstream document — each entry SHALL contain `vid` (from `_vid`), `modified` (ISO timestamp), `user` (from `_vuser`), and `note` (from `_vnote`). Full form snapshots (`components`, `access`, `settings`, etc.) are intentionally omitted from the summary; callers needing a single revision's full body SHALL use `form_revision_get`. + +#### Scenario: List revisions by form id returns compact summaries + +- **WHEN** the caller invokes `form_revisions_list` with `cwd` and a `formIdOrPath` that is a Mongo `_id` +- **THEN** the tool issues `GET ${FORMIO_PROJECT_URL}/form/{id}/v` and returns an array of summary objects (each with `vid`, `modified`, `user`, `note`) wrapped via `toMcpTextResult`. Full form snapshot fields (e.g. `components`, `access`, `_id`, `_rid`, `revisionId`) are NOT included in the summary entries. + +#### Scenario: List revisions by form path + +- **WHEN** the caller invokes `form_revisions_list` with a `formIdOrPath` that is not a Mongo id +- **THEN** the tool first resolves the path to an `_id` via `formioFetch`, then issues `GET /form/{id}/v` and returns the summarized result + +#### Scenario: Form not found + +- **WHEN** the upstream call returns 404 +- **THEN** the tool returns an `toMcpError` payload preserving the upstream message and status + +#### Scenario: Tool description points to `form_revision_get` for full bodies + +- **WHEN** an MCP client lists the server's tools and inspects `form_revisions_list`'s description +- **THEN** the description states the four summary fields (`vid`, `modified`, `user`, `note`) and points the caller to `form_revision_get` for fetching a specific revision's full form definition + +### Requirement: Get form at a specific revision + +The MCP server SHALL expose a `form_revision_get` tool that returns the full form definition at a specific revision, identified by either a sequential version number or a revision `_id`. + +#### Scenario: Fetch by sequential version + +- **WHEN** the caller invokes `form_revision_get` with `formIdOrPath` and `version: "2"` +- **THEN** the tool issues `GET /form/{id}/v/2` and returns the revision document + +#### Scenario: Fetch by revision id + +- **WHEN** the caller invokes `form_revision_get` with a `version` that is a Mongo `_id` +- **THEN** the tool issues `GET /form/{id}/v/{revisionId}` and returns the revision document + +#### Scenario: Revision missing + +- **WHEN** the upstream call returns 404 +- **THEN** the tool returns a `toMcpError` payload identifying the form and version requested + +### Requirement: Set form revisions mode (enable, switch, or disable) + +The MCP server SHALL expose a `form_revisions_set` tool that sets the form's `revisions` field. The tool SHALL accept a **required** `mode: "current" | "original" | ""` argument matching the on-disk wire values: `"current"` and `"original"` enable revisions (display mode for historical submissions); `""` disables revisions. There SHALL be NO default — calls without `mode` SHALL be rejected by the input schema so the agent confirms intent with the user. The tool SHALL avoid clobbering unrelated fields by performing a GET → merge → PUT. + +#### Scenario: Enable on a form that does not yet have revisions + +- **WHEN** the caller invokes `form_revisions_set` for a form with `revisions: ""`, with `mode: "current"` +- **THEN** the tool fetches the current form via `GET /form/{id}`, sets `revisions: "current"` on the body (preserving all other fields), and issues `PUT /form/{id}` with the merged body, returning the updated form + +#### Scenario: Disable revisions on a revisioned form + +- **WHEN** the caller invokes `form_revisions_set` for a form whose `revisions` is `"current"` or `"original"`, with `mode: ""` +- **THEN** the tool issues `PUT /form/{id}` with `revisions: ""` on the merged body, returning the updated form. The server preserves `_vid` and existing `formrevisions` rows; only the form's `revisions` field flips. + +#### Scenario: Mode is required + +- **WHEN** the caller invokes `form_revisions_set` without supplying `mode` +- **THEN** the Zod input schema rejects the call with a validation error naming `mode`, and no upstream HTTP request is made + +#### Scenario: Tool description directs the agent to confirm intent + +- **WHEN** an MCP client lists the server's tools and inspects `form_revisions_set`'s description and the `mode` parameter description +- **THEN** the description states that `mode` is required, lists all three valid values (`"current"`, `"original"`, `""`), and tells the agent to confirm intent with the user rather than guessing + +#### Scenario: Enable with mode "original" + +- **WHEN** the caller invokes `form_revisions_set` with `mode: "original"` +- **THEN** the merged body sent to `PUT /form/{id}` carries `revisions: "original"`, not `"current"` and not `""` + +#### Scenario: No-op when already at requested mode + +- **WHEN** the caller invokes `form_revisions_set` for a form whose `revisions` field already matches the requested `mode` (including `mode: ""` on an already-disabled form) +- **THEN** the tool returns the existing form without issuing a `PUT` + +#### Scenario: Switch mode between current and original + +- **WHEN** the caller invokes `form_revisions_set` with a `mode` different from the form's current `revisions` value +- **THEN** the tool issues a `PUT /form/{id}` that flips the value and returns the updated form + +#### Scenario: Form not found + +- **WHEN** the initial `GET /form/{id}` returns 404 +- **THEN** the tool returns a `toMcpError` payload and does not issue a `PUT` + +#### Scenario: License-gated rejection + +- **WHEN** the upstream `PUT /form/{id}` returns 402 or 403 because the project lacks the Security Module / form-revisions license +- **THEN** the tool returns a `toMcpError` payload preserving the upstream status and message verbatim + +### Requirement: Get the current draft of a revisioned form + +The MCP server SHALL expose a `form_draft_get` tool that returns the form's currently saved draft (the single `_vid: "draft"` row in `formrevisions`). The tool SHALL accept either a form `_id` or a path and resolve paths to `_id` before issuing the upstream request. This tool is distinct from `form_revision_get`: that tool reads an immutable numbered revision via `GET /form/{id}/v/{version}`, whereas `form_draft_get` reads the mutable WIP draft via `GET /form/{id}/draft`. The tool description SHALL state this distinction so the agent picks the correct tool. + +#### Scenario: Fetch draft by form id + +- **WHEN** the caller invokes `form_draft_get` with `cwd` and a `formIdOrPath` that is a Mongo `_id` +- **THEN** the tool issues `GET /form/{id}/draft` and returns the draft document via `toMcpTextResult` + +#### Scenario: Fetch draft by form path + +- **WHEN** the caller invokes `form_draft_get` with a `formIdOrPath` that is not a Mongo id +- **THEN** the tool first resolves the path to an `_id` via `formioFetch`, then issues `GET /form/{id}/draft` and returns the draft document + +#### Scenario: No draft exists + +- **WHEN** the upstream `GET /form/{id}/draft` returns 404 because no draft has been saved (or revisions are not enabled) +- **THEN** the tool returns a `toMcpError` payload preserving the upstream message and status + +#### Scenario: Tool description distinguishes draft from numbered revision + +- **WHEN** an MCP client lists the server's tools and inspects `form_draft_get`'s description +- **THEN** the description states that this tool reads the mutable active draft (the `_vid: "draft"` row) and points the caller to `form_revision_get` for immutable numbered revisions + +### Requirement: Create or update the draft of a revisioned form + +The MCP server SHALL expose a `form_draft_create` tool that saves a draft revision for a form, defaulting to copying the current published form when no explicit definition is supplied, and accepting an optional `note: string` argument that is forwarded as `_vnote` on the saved draft row. The tool description SHALL state that a form has at most one active draft at a time and that calling `form_draft_create` overwrites any existing draft. + +After issuing the `PUT /form/{id}/draft`, the tool SHALL issue a follow-up `GET /form/{id}/draft` and return the GET response (not the PUT response). This works around an upstream Form.io server bug: `FormResource.putDraft` calls Mongoose `findOneAndUpdate` without `{ new: true }` on overwrite, so the PUT response is the **pre-update** document. The re-fetch returns ground truth, mirroring how the portal sidesteps the bug via a full page reload after each draft save. The workaround SHALL be removed once the upstream bug is fixed. + +#### Scenario: Save explicit draft body + +- **WHEN** the caller invokes `form_draft_create` with `formIdOrPath` and a `definition` object +- **THEN** the tool issues `PUT /form/{id}/draft` with the supplied definition (no `_vnote` if `note` was not supplied) and returns the saved draft + +#### Scenario: Default to copying current published form + +- **WHEN** the caller invokes `form_draft_create` with `formIdOrPath` and no `definition` +- **THEN** the tool fetches the current form via `GET /form/{id}` and issues `PUT /form/{id}/draft` with that body, returning the saved draft + +#### Scenario: Save draft with a note + +- **WHEN** the caller invokes `form_draft_create` with `formIdOrPath` and `note: "wip-add-email-field"` +- **THEN** the body sent to `PUT /form/{id}/draft` includes `_vnote: "wip-add-email-field"` at the top level, and the resulting `formrevisions` row at `_vid: "draft"` carries that note + +#### Scenario: Revisions not enabled + +- **WHEN** the upstream `PUT /form/{id}/draft` returns 404 because the form does not have revisions enabled +- **THEN** the tool returns a `toMcpError` payload whose message guides the caller to invoke `form_revisions_set` first + +#### Scenario: Tool description warns of overwrite + +- **WHEN** an MCP client lists the server's tools and inspects `form_draft_create`'s description +- **THEN** the description states that a form may have only one active draft at a time and that this tool overwrites any existing draft + +#### Scenario: Returns post-PUT GET, not stale PUT response + +- **WHEN** the caller invokes `form_draft_create` to overwrite an existing draft and the upstream `PUT /form/{id}/draft` returns the pre-update document +- **THEN** the tool issues a follow-up `GET /form/{id}/draft` and returns the GET response (carrying the new `_vnote` and other just-saved fields), not the stale PUT response + +### Requirement: Publish a draft form with an optional revision note + +The MCP server SHALL expose a `form_draft_publish` tool that promotes the saved draft to the next published revision, accepting an optional `note: string` argument that is forwarded as the published revision's note (the same value the portal collects under "Revision notes"), with an optional `definition` override to publish a caller-supplied body without going through the saved draft. + +The tool SHALL strip any `_vnote` field from the resolved publish body BEFORE issuing the PUT, regardless of whether the body came from the merged draft path or the caller-supplied `definition` path. The published revision's `_vnote` SHALL be set EXCLUSIVELY by the explicit `note` argument — a draft's own `_vnote` SHALL NOT carry over to the published revision. This matches portal behavior, where the draft note (work-in-progress) and the publish note (revision note) are independent concepts. + +The tool description SHALL explicitly forbid the calling agent from auto-populating `note` based on the draft's `_vnote`. Both the tool description and the `note` parameter description SHALL instruct the agent to leave `note` undefined unless the user has explicitly stated what the published revision's note should be. + +#### Scenario: Publish saved draft + +- **WHEN** the caller invokes `form_draft_publish` with `formIdOrPath`, no `definition`, and no `note` +- **THEN** the tool fetches the current draft via `GET /form/{id}/draft`, issues `PUT /form/{id}` with that body, and returns the published form + +#### Scenario: Publish saved draft with a note + +- **WHEN** the caller invokes `form_draft_publish` with `formIdOrPath` and `note: "Added email field"` +- **THEN** the tool fetches the current draft, attaches the note to the publish body via the API's revision-note field, issues `PUT /form/{id}`, and returns the published form whose listing entry exposes that note via `form_revisions_list` + +#### Scenario: Publish caller-supplied definition + +- **WHEN** the caller invokes `form_draft_publish` with `formIdOrPath` and a `definition` +- **THEN** the tool issues `PUT /form/{id}` with the supplied definition (plus `note` if provided) and returns the published form, without first fetching the draft + +#### Scenario: No draft exists + +- **WHEN** the caller invokes `form_draft_publish` without a `definition` and `GET /form/{id}/draft` returns 404 +- **THEN** the tool returns a `toMcpError` payload indicating no draft exists + +#### Scenario: `_vid` conflict on publish + +- **WHEN** the publishing `PUT /form/{id}` returns 409 +- **THEN** the tool returns a `toMcpError` payload preserving the conflict message so the caller can retry + +#### Scenario: Publish with no diff vs current published form + +- **WHEN** the caller invokes `form_draft_publish` and the resolved publish body matches the current published form exactly (ignoring `_vnote`) +- **THEN** the tool returns the form (HTTP 200, success) without surfacing an error, and the response signals to the caller that no new revision was created — the MCP tool description SHALL document this no-op behavior so the calling LLM knows to vary the body if a `_vid` bump is required + +#### Scenario: Server auto-clears the saved draft after a successful publish + +- **WHEN** `form_draft_publish` issues a `PUT /form/{id}` that creates a new numeric revision row +- **THEN** the tool does NOT issue a separate delete of the draft row; the upstream server removes the `_vid: "draft"` row automatically as part of the publish + +#### Scenario: Draft's `_vnote` does NOT transfer to the published revision when no `note` arg supplied + +- **WHEN** the caller invokes `form_draft_publish` without supplying a `note` argument and the saved draft has a non-empty `_vnote` +- **THEN** the publish body sent to `PUT /form/{id}` SHALL NOT contain `_vnote`, and the resulting numbered revision row's `_vnote` SHALL be the empty string `""` — matching portal behavior where the draft note and the publish note are independent + +#### Scenario: Caller-supplied `definition._vnote` is stripped before publishing + +- **WHEN** the caller invokes `form_draft_publish` with a `definition` object that contains `_vnote`, and no `note` argument +- **THEN** the publish body sent to `PUT /form/{id}` SHALL NOT contain the `_vnote` from the supplied `definition`; the published revision's `_vnote` SHALL be empty unless the caller also passes a `note` argument + +#### Scenario: Tool description forbids the agent from auto-inferring `note` from the draft + +- **WHEN** an MCP client lists the server's tools and inspects `form_draft_publish`'s description and the `note` parameter description +- **THEN** both descriptions explicitly instruct the agent NOT to auto-populate `note` from the draft's `_vnote`, state that draft notes and publish notes are independent concepts, and direct the agent to leave `note` undefined unless the user has explicitly stated what the published revision's note should be + +### Requirement: Tool registration + +All six tools SHALL be registered through `registerAllTools` in [packages/mcp-server/src/tools/index.ts](packages/mcp-server/src/tools/index.ts) and SHALL accept `cwd` (the standard `cwdSchema`) so the project resolver applies. + +#### Scenario: Server exposes the new tools + +- **WHEN** `registerAllTools` runs against an `McpServer` instance +- **THEN** the server's tool list includes `form_revisions_list`, `form_revision_get`, `form_revisions_set`, `form_draft_create`, `form_draft_get`, and `form_draft_publish`, each with a `cwd` argument + +### Requirement: Skill reference points to the new tools + +The reference document at [plugin/skills/formio-api/references/project-form-revisions.md](plugin/skills/formio-api/references/project-form-revisions.md) SHALL replace its current "No MCP tool covers this operation" note with a `## MCP Tool Preference` block that names the six new tools and maps each to the endpoint it covers. + +#### Scenario: MCP Tool Preference section names the new tools + +- **WHEN** a reader opens `project-form-revisions.md` +- **THEN** the `## MCP Tool Preference` section instructs Claude to prefer `form_revisions_list`, `form_revision_get`, `form_revisions_set`, `form_draft_create`, `form_draft_get`, and `form_draft_publish` over raw HTTP calls for the operations they cover + +#### Scenario: Skills validator still passes + +- **WHEN** `pnpm test` runs after the reference is updated +- **THEN** the skills-validator suite passes, confirming the reference still carries the canonical portal-login JWT auth paragraph and required heading layout diff --git a/openspec/changes/add-form-revisions-mcp-tools/tasks.md b/openspec/changes/add-form-revisions-mcp-tools/tasks.md new file mode 100644 index 0000000..451b9bb --- /dev/null +++ b/openspec/changes/add-form-revisions-mcp-tools/tasks.md @@ -0,0 +1,161 @@ +## 1. form_revisions_list + + +### Red + +- [x] 1.1 Write failing test: tool returns the array from `GET /form/{id}/v` when called with a Mongo `_id` and forwards `cwd` through `resolveProjectConfig` +- [x] 1.2 Write failing test: tool resolves a path argument to an `_id` via `formioFetch` before calling `/v` +- [x] 1.3 Write failing test: tool returns a `toMcpError` payload preserving the upstream message when `formioFetch` throws a 404 +- [x] 1.4 Snapshot a real `GET /form/{id}/v` response — confirmed wire fields per `formrevisions` doc: `_id`, `_rid`, `revisionId`, `_vid`, `_vnote`, `_vuser`, `modified`, plus full form snapshot. Commit the fixture so future shape changes break loudly + +### Green + +- [x] 1.5 Implement `packages/mcp-server/src/tools/form_revisions_list.ts` (registers `form_revisions_list`, accepts `cwd` + `formIdOrPath`, uses `isMongoId` + `formioFetch`, wraps with `toMcpTextResult` / `toMcpError`) +- [x] 1.6 Wire `registerFormRevisionsListTool` into `registerAllTools` in [packages/mcp-server/src/tools/index.ts](packages/mcp-server/src/tools/index.ts) +- [x] 1.7 If the upstream listing omits any of `vid` / `modified` / `owner` / `note`, document the gap explicitly in the tool's description string so callers don't expect fields that aren't there + +### Refactor + +- [x] 1.8 Review implementation and refactor as needed + +## 2. form_revision_get + + +### Red + +- [x] 2.1 Write failing test: tool fetches `GET /form/{id}/v/{version}` for a sequential numeric version +- [x] 2.2 Write failing test: tool fetches `GET /form/{id}/v/{revisionId}` when `version` is a Mongo `_id` +- [x] 2.3 Write failing test: tool resolves a path-style `formIdOrPath` to an `_id` first +- [x] 2.4 Write failing test: missing revision (upstream 404) surfaces via `toMcpError` with form + version in the message + +### Green + +- [x] 2.5 Implement `packages/mcp-server/src/tools/form_revision_get.ts` accepting `cwd`, `formIdOrPath`, `version: string | number` +- [x] 2.6 Register the tool in `registerAllTools` + +### Refactor + +- [x] 2.7 Review implementation and refactor as needed + +## 3. form_revisions_set + + +Single tool covers enable, switch, and disable via the form's `revisions` field. Required `mode` enum is the wire value: `"current" | "original" | ""`. + +### Red + +- [x] 3.1 Write failing test: with `mode: "current"` on a form whose `revisions` is `""`, tool fetches the form, merges `revisions: "current"`, and `PUT`s the merged body. Assert exactly one `PUT` is issued; the server-side side-effect of creating the v1 `formrevisions` row is out of the tool's responsibility +- [x] 3.2 Write failing test: with `mode: "original"`, the merged body sent to `PUT /form/{id}` carries `revisions: "original"` +- [x] 3.3 Write failing test: with `mode: ""` on a revisioned form, the merged body sent to `PUT /form/{id}` carries `revisions: ""` (disable). Assert exactly one `PUT` is issued +- [x] 3.4 Write failing test: invocation without `mode` fails Zod validation, no upstream HTTP is issued, and the error names `mode` +- [x] 3.5 Write failing test: invocation with an unknown mode value (e.g. `"bogus"`) fails Zod validation, no upstream HTTP is issued +- [x] 3.6 Write failing test: tool description and the `mode` parameter description (passed to `server.tool(...)`) tell the agent to confirm intent with the user and list all three valid values +- [x] 3.7 Write failing test: when current form already has the requested `mode` (including `""` on an already-disabled form), tool short-circuits and returns the existing form without issuing a `PUT` +- [x] 3.8 Write failing test: when current form has a different `revisions` value, tool issues a `PUT` flipping it +- [x] 3.9 Write failing test: initial `GET /form/{id}` 404 surfaces via `toMcpError` and no `PUT` is issued +- [x] 3.10 Write failing test: upstream 402/403 (Security Module / license rejection) surfaces verbatim through `toMcpError` +- [x] 3.11 Pin assertions against the confirmed shape: `revisions` is a single string field on the form doc (values `""` / `"current"` / `"original"`). Snapshot enable + disable wire fixtures so the tests assert against real wire data + +### Green + +- [x] 3.12 Implement `packages/mcp-server/src/tools/form_revisions_set.ts` (GET → mutate → PUT; `mode` is a required Zod enum `["current", "original", ""]` with no default; no-op short-circuit; tool/parameter descriptions tell the agent to confirm intent). Do NOT issue a separate seed write for the first `formrevisions` row — same PUT inserts v1 row automatically. On disable, server preserves `_vid` and existing rows. +- [x] 3.13 Register the tool in `registerAllTools` + +### Refactor + +- [x] 3.14 Review implementation and refactor as needed + +## 4. form_draft_create + + +### Red + +- [x] 4.1 Write failing test: with an explicit `definition` and no `note`, tool issues `PUT /form/{id}/draft` with that body and no `_vnote` field +- [x] 4.2 Write failing test: with an explicit `definition` and `note: "wip"`, tool issues `PUT /form/{id}/draft` with `_vnote: "wip"` at the top level of the body +- [x] 4.3 Write failing test: without `definition`, tool fetches the current form via `GET /form/{id}` and `PUT`s that body to `/draft` +- [x] 4.4 Write failing test: upstream 404 on `PUT /draft` (revisions not enabled) returns a `toMcpError` whose message instructs the caller to run `form_revisions_set` first +- [x] 4.5 Write failing test: tool's MCP description (passed as the second arg to `server.tool(...)`) contains the substring "one active draft" and "overwrites" so MCP clients listing the tool see the warning + +### Green + +- [x] 4.6 Implement `packages/mcp-server/src/tools/form_draft_create.ts` accepting `cwd`, `formIdOrPath`, optional `definition`, optional `note`, with the warning surfaced in the description string +- [x] 4.7 Register the tool in `registerAllTools` + +### Refactor + +- [x] 4.8 Review implementation and refactor as needed + +## 5. form_draft_get + + +### Red + +- [x] 5.1 Write failing test: with a Mongo `_id` formIdOrPath, tool issues a single `GET /form/{id}/draft` and returns the draft via `toMcpTextResult` +- [x] 5.2 Write failing test: with a path-style formIdOrPath, tool resolves the path to an `_id` first via `formioFetch`, then issues `GET /form/{id}/draft` +- [x] 5.3 Write failing test: upstream 404 (no draft saved or revisions disabled) surfaces via `toMcpError` preserving the upstream message +- [x] 5.4 Write failing test: tool MCP description distinguishes the active draft from numbered revisions and points callers to `form_revision_get` for immutable history (substrings: "draft", "form_revision_get") + +### Green + +- [x] 5.5 Implement `packages/mcp-server/src/tools/form_draft_get.ts` accepting `cwd` + `formIdOrPath`, mirroring `form_revision_get.ts` in shape (cwd-resolved config, `isMongoId` short-circuit, `formioFetch`, `toMcpTextResult` / `toMcpError`) +- [x] 5.6 Wire `registerFormDraftGetTool` into `registerAllTools` in [packages/mcp-server/src/tools/index.ts](packages/mcp-server/src/tools/index.ts) + +### Refactor + +- [x] 5.7 Review implementation and refactor as needed + +## 6. form_draft_publish + + +### Red + +- [x] 6.1 Write failing test: with no `definition` and no `note`, tool fetches `GET /form/{id}/draft` and publishes via `PUT /form/{id}` with that body +- [x] 6.2 Write failing test: with a `note: "Added email field"` and no `definition`, the body sent to `PUT /form/{id}` includes `_vnote: "Added email field"` (field name confirmed against `mcp.formrevisions`) +- [x] 6.3 Write failing test: with a `definition`, tool publishes via `PUT /form/{id}` using the supplied body, skips the draft GET, and still attaches `note` if provided +- [x] 6.4 Write failing test: missing draft (upstream 404 on draft GET) surfaces via `toMcpError` indicating no draft exists +- [x] 6.5 Write failing test: 409 conflict from publishing `PUT` is preserved through `toMcpError` +- [x] 6.6 Write failing test: when the publish body matches the current published form (ignoring `_vnote`), the tool returns the form (success — not an error) and the response indicates no new revision was created +- [x] 6.7 Write failing test: tool description states (a) note rides as `_vnote`, (b) server auto-clears the draft row when a real revision is created, (c) publish is a no-op against an unchanged body +- [x] 6.8 Snapshot the publish wire format (already pinned via Playwright capture): top-level `_vnote` in the `PUT /form/{id}` request body, response strips it, new `formrevisions` row carries the value plus `_vuser`. Commit the captured request/response pair as a fixture so future drift breaks loudly. Also snapshot the draft-save wire (`PUT /form/{id}/draft` with top-level `_vnote`) — verified end-to-end and the server upserts a `formrevisions` row with `_vid: "draft"` + +### Green + +- [x] 6.9 Implement `packages/mcp-server/src/tools/form_draft_publish.ts` accepting `cwd`, `formIdOrPath`, optional `definition`, optional `note`. Do NOT issue a separate delete of the draft row — server clears it automatically when a real revision is created +- [x] 6.10 Register the tool in `registerAllTools` + +### Refactor + +- [x] 6.11 Review implementation and refactor as needed + +## 7. Skill reference update + + +### Red + +- [x] 7.1 Write failing skills-validator (or string-content) test asserting `plugin/skills/formio-api/references/project-form-revisions.md`'s `## MCP Tool Preference` section names all six new tools (`form_revisions_list`, `form_revision_get`, `form_revisions_set`, `form_draft_create`, `form_draft_get`, `form_draft_publish`) + +### Green + +- [x] 7.2 Replace the `## MCP Tool Preference` block in `project-form-revisions.md` to map each new tool to the endpoint(s) it covers, keeping the canonical portal-login JWT auth paragraph intact +- [x] 7.3 Confirm `pnpm test` passes (skills-validator suite still green) + +### Refactor + +- [x] 7.4 Review implementation and refactor as needed + +## 8. Definition of Done + + +### Red + +- [x] 8.1 (No new tests — this group exercises the existing suite end-to-end) + +### Green + +- [x] 8.2 Run `pnpm test` and resolve any failures +- [x] 8.3 Run `pnpm lint` (typecheck) and resolve any errors +- [x] 8.4 Run `pnpm format` and commit any formatting deltas + +### Refactor + +- [x] 8.5 Review implementation and refactor as needed diff --git a/packages/mcp-server/src/__tests__/form_draft_create.test.ts b/packages/mcp-server/src/__tests__/form_draft_create.test.ts new file mode 100644 index 0000000..7d2738d --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_draft_create.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormDraftCreateTool } = await import('../tools/form_draft_create.js'); + +const FORM_ID = '69f8f9ca71592601fd814e0f'; + +describe('form_draft_create tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('is listed in available tools', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftCreateTool); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain('form_draft_create'); + }); + + it('description warns about single active draft and overwrite', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftCreateTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_draft_create'); + expect(tool!.description).toMatch(/one active draft/i); + expect(tool!.description).toMatch(/overwrites/i); + }); + + it('description forbids fallback to form_update on failure', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftCreateTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_draft_create'); + expect(tool!.description).toMatch(/do not.*form_update/i); + expect(tool!.description).toMatch(/surface/i); + }); + + it('with explicit definition and no note, GETs current form, overlays draft fields, PUTs merged body to /draft', async () => { + const current = { + _id: FORM_ID, + title: 'test5', + revisions: 'original', + access: [{ type: 'read_all', roles: ['r1'] }], + submissionAccess: [{ type: 'create_own', roles: ['r1'] }], + owner: 'owner1', + project: 'p1', + components: [{ type: 'textfield', key: 'old' }], + }; + const definition = { + components: [{ type: 'button', key: 'submit' }], + }; + mockFormioFetch + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({ _vid: 'draft' }) + .mockResolvedValueOnce({ _vid: 'draft' }); + const { client } = await createTestClient(registerFormDraftCreateTool); + + await client.callTool({ + name: 'form_draft_create', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, definition }, + }); + + expect(mockFormioFetch).toHaveBeenNthCalledWith(1, `form/${FORM_ID}`, {}, TEST_CONFIG); + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall![0]).toBe(`form/${FORM_ID}/draft`); + expect(putCall![3].body).toEqual({ + _id: FORM_ID, + title: 'test5', + revisions: 'original', + access: current.access, + submissionAccess: current.submissionAccess, + owner: 'owner1', + project: 'p1', + components: definition.components, + }); + expect(putCall![3].body).not.toHaveProperty('_vnote'); + }); + + it('with explicit definition, only overlays draft-specific fields (components, settings, tags, properties, display) — preserves form-level fields', async () => { + const current = { + _id: FORM_ID, + title: 'keep', + name: 'keep', + path: 'keep', + revisions: 'original', + access: [{ type: 'read_all', roles: ['r1'] }], + submissionAccess: [{ type: 'create_own', roles: ['r1'] }], + owner: 'owner1', + components: [{ key: 'old' }], + settings: { keep: true }, + tags: ['old-tag'], + properties: { foo: 'old' }, + display: 'wizard', + }; + const definition = { + title: 'NEW-TITLE-IGNORED', + revisions: 'IGNORED', + access: [], + owner: 'IGNORED', + components: [{ key: 'new' }], + settings: { keep: false }, + tags: ['new-tag'], + properties: { foo: 'new' }, + display: 'form', + }; + mockFormioFetch + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({ _vid: 'draft' }) + .mockResolvedValueOnce({ _vid: 'draft' }); + const { client } = await createTestClient(registerFormDraftCreateTool); + + await client.callTool({ + name: 'form_draft_create', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, definition }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall![3].body).toEqual({ + _id: FORM_ID, + title: 'keep', + name: 'keep', + path: 'keep', + revisions: 'original', + access: current.access, + submissionAccess: current.submissionAccess, + owner: 'owner1', + components: definition.components, + settings: definition.settings, + tags: definition.tags, + properties: definition.properties, + display: definition.display, + }); + }); + + it('with explicit definition and note, attaches _vnote at top level of merged body', async () => { + const current = { + _id: FORM_ID, + revisions: 'original', + owner: 'owner1', + components: [{ key: 'old' }], + }; + const definition = { + components: [{ type: 'button', key: 'submit' }], + }; + mockFormioFetch + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({ _vid: 'draft' }) + .mockResolvedValueOnce({ _vid: 'draft' }); + const { client } = await createTestClient(registerFormDraftCreateTool); + + await client.callTool({ + name: 'form_draft_create', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, definition, note: 'wip' }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall![0]).toBe(`form/${FORM_ID}/draft`); + expect(putCall![3].body).toEqual( + expect.objectContaining({ + _vnote: 'wip', + revisions: 'original', + owner: 'owner1', + components: definition.components, + }) + ); + }); + + it('without definition, GETs current form then PUTs that body to /draft', async () => { + const currentForm = { + _id: FORM_ID, + title: 'test5', + components: [{ type: 'button', key: 'submit' }], + revisions: 'current', + _vid: 1, + }; + mockFormioFetch + .mockResolvedValueOnce(currentForm) + .mockResolvedValueOnce(currentForm) + .mockResolvedValueOnce(currentForm); + const { client } = await createTestClient(registerFormDraftCreateTool); + + await client.callTool({ + name: 'form_draft_create', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + expect(mockFormioFetch).toHaveBeenNthCalledWith(1, `form/${FORM_ID}`, {}, TEST_CONFIG); + expect(mockFormioFetch).toHaveBeenNthCalledWith( + 2, + `form/${FORM_ID}/draft`, + {}, + TEST_CONFIG, + expect.objectContaining({ method: 'PUT', body: currentForm }) + ); + expect(mockFormioFetch).toHaveBeenNthCalledWith(3, `form/${FORM_ID}/draft`, {}, TEST_CONFIG); + }); + + it('returns the post-PUT GET result, not the stale PUT response', async () => { + const currentForm = { + _id: FORM_ID, + components: [{ type: 'button', key: 'submit' }], + revisions: 'original', + }; + const stalePutResponse = { _vid: 'draft', _vnote: 'old-note' }; + const freshGetResponse = { _vid: 'draft', _vnote: 'new-note' }; + mockFormioFetch + .mockResolvedValueOnce(currentForm) + .mockResolvedValueOnce(stalePutResponse) + .mockResolvedValueOnce(freshGetResponse); + const { client } = await createTestClient(registerFormDraftCreateTool); + + const result = await client.callTool({ + name: 'form_draft_create', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, note: 'new-note' }, + }); + + const text = (result.content as { text: string }[])[0].text; + expect(text).toContain('new-note'); + expect(text).not.toContain('old-note'); + }); + + it('returns isError true and guides caller when revisions not enabled (404)', async () => { + mockFormioFetch.mockRejectedValue(new Error('Form.io API error: 404 Not Found')); + const { client } = await createTestClient(registerFormDraftCreateTool); + + const result = await client.callTool({ + name: 'form_draft_create', + arguments: { + cwd: TEST_CWD, + formIdOrPath: FORM_ID, + definition: { components: [] }, + }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as { text: string }[])[0].text; + expect(text).toContain('404'); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_draft_get.test.ts b/packages/mcp-server/src/__tests__/form_draft_get.test.ts new file mode 100644 index 0000000..676b767 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_draft_get.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormDraftGetTool } = await import('../tools/form_draft_get.js'); + +describe('form_draft_get tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('is listed in available tools', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftGetTool); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain('form_draft_get'); + }); + + it('fetches the draft by Mongo form _id with a single GET', async () => { + const id = '69f8f9ca71592601fd814e0f'; + const draft = { _id: 'd1', _vid: 'draft', _rid: id, components: [] }; + mockFormioFetch.mockResolvedValue(draft); + const { client } = await createTestClient(registerFormDraftGetTool); + + await client.callTool({ + name: 'form_draft_get', + arguments: { cwd: TEST_CWD, formIdOrPath: id }, + }); + + expect(mockFormioFetch).toHaveBeenCalledTimes(1); + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${id}/draft`, {}, TEST_CONFIG); + }); + + it('resolves a path-style formIdOrPath to an _id first', async () => { + const formId = '69f8f9ca71592601fd814e0f'; + mockFormioFetch + .mockResolvedValueOnce({ _id: formId, name: 'test5' }) + .mockResolvedValueOnce({ _vid: 'draft' }); + const { client } = await createTestClient(registerFormDraftGetTool); + + await client.callTool({ + name: 'form_draft_get', + arguments: { cwd: TEST_CWD, formIdOrPath: 'test5' }, + }); + + expect(mockFormioFetch).toHaveBeenNthCalledWith(1, 'test5', {}, TEST_CONFIG); + expect(mockFormioFetch).toHaveBeenNthCalledWith(2, `form/${formId}/draft`, {}, TEST_CONFIG); + }); + + it('returns the draft JSON as MCP text content', async () => { + const id = '69f8f9ca71592601fd814e0f'; + const draft = { _id: 'd1', _vid: 'draft', components: [{ key: 'name' }] }; + mockFormioFetch.mockResolvedValue(draft); + const { client } = await createTestClient(registerFormDraftGetTool); + + const result = await client.callTool({ + name: 'form_draft_get', + arguments: { cwd: TEST_CWD, formIdOrPath: id }, + }); + + expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(draft, null, 2) }]); + }); + + it('returns isError true on upstream 404 (no draft saved)', async () => { + mockFormioFetch.mockRejectedValue(new Error('Form.io API error: 404 Not Found')); + const { client } = await createTestClient(registerFormDraftGetTool); + + const result = await client.callTool({ + name: 'form_draft_get', + arguments: { + cwd: TEST_CWD, + formIdOrPath: '69f8f9ca71592601fd814e0f', + }, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ type: 'text', text: expect.stringContaining('404') }), + ]); + }); + + it('description distinguishes the draft from numbered revisions', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftGetTool); + + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_draft_get'); + + expect(tool).toBeDefined(); + expect(tool!.description).toMatch(/draft/i); + expect(tool!.description).toMatch(/form_revision_get/); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_draft_publish.test.ts b/packages/mcp-server/src/__tests__/form_draft_publish.test.ts new file mode 100644 index 0000000..075e40a --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_draft_publish.test.ts @@ -0,0 +1,368 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormDraftPublishTool } = await import('../tools/form_draft_publish.js'); + +const FORM_ID = '69f8f9ca71592601fd814e0f'; + +describe('form_draft_publish tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('is listed in available tools', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftPublishTool); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain('form_draft_publish'); + }); + + it('description states note rides as _vnote, server auto-clears draft, no-op against unchanged body', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftPublishTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_draft_publish'); + expect(tool!.description).toMatch(/_vnote/); + expect(tool!.description).toMatch(/auto.?clear/i); + expect(tool!.description).toMatch(/no-op/i); + }); + + it('description marks itself the canonical "add a new revision" path', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftPublishTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_draft_publish'); + expect(tool!.description).toMatch(/add (a |the )?(new )?revision/i); + }); + + it('description forbids fallback to form_update on failure', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftPublishTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_draft_publish'); + expect(tool!.description).toMatch(/do not.*form_update/i); + expect(tool!.description).toMatch(/surface/i); + }); + + it("description forbids auto-inferring `note` from the draft's `_vnote`", async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormDraftPublishTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_draft_publish'); + expect(tool!.description).toMatch( + /do not infer|do NOT infer|do not auto-?forward|do NOT auto-?forward/ + ); + expect(tool!.description).toMatch(/independent/i); + const noteParam = (tool!.inputSchema as { properties?: { note?: { description?: string } } }) + .properties?.note; + expect(noteParam?.description).toMatch(/do not auto-?populate|do NOT auto-?populate/i); + expect(noteParam?.description).toMatch(/explicitly stated|explicit/i); + }); + + it('with no definition and no note, fetches draft + current form then PUTs merged body', async () => { + const current = { + _id: FORM_ID, + title: 'test5', + revisions: 'original', + access: [{ type: 'read_all', roles: ['r1'] }], + submissionAccess: [{ type: 'create_own', roles: ['r1'] }], + owner: 'owner1', + components: [{ type: 'textfield', key: 'old' }], + }; + const draft = { + _id: FORM_ID, + title: 'test5', + components: [{ type: 'email', key: 'email' }], + }; + const published = { ...current, ...draft, _vid: 2 }; + mockFormioFetch + .mockResolvedValueOnce(draft) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(published); + const { client } = await createTestClient(registerFormDraftPublishTool); + + await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + expect(mockFormioFetch).toHaveBeenNthCalledWith(1, `form/${FORM_ID}/draft`, {}, TEST_CONFIG); + expect(mockFormioFetch).toHaveBeenNthCalledWith(2, `form/${FORM_ID}`, {}, TEST_CONFIG); + expect(mockFormioFetch).toHaveBeenNthCalledWith( + 3, + `form/${FORM_ID}`, + {}, + TEST_CONFIG, + expect.objectContaining({ method: 'PUT', body: { ...current, ...draft } }) + ); + }); + + it('publish preserves form-level fields (revisions, access, submissionAccess, owner) when draft body lacks them', async () => { + const current = { + _id: FORM_ID, + title: 'test5', + revisions: 'original', + access: [{ type: 'read_all', roles: ['r1'] }], + submissionAccess: [{ type: 'create_own', roles: ['r1'] }], + owner: 'owner1', + components: [{ type: 'textfield', key: 'old' }], + }; + const draft = { + _id: FORM_ID, + components: [{ type: 'email', key: 'email' }], + }; + mockFormioFetch + .mockResolvedValueOnce(draft) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({ ...current, ...draft, _vid: 2 }); + const { client } = await createTestClient(registerFormDraftPublishTool); + + await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall![3].body).toEqual( + expect.objectContaining({ + revisions: 'original', + access: current.access, + submissionAccess: current.submissionAccess, + owner: 'owner1', + components: draft.components, + }) + ); + }); + + it('selective overlay: draft fields outside the whitelist (access, owner, revisions, _id) do NOT override current form', async () => { + const current = { + _id: FORM_ID, + title: 'keep', + name: 'keep', + path: 'keep', + revisions: 'original', + access: [{ type: 'read_all', roles: ['real-role'] }], + submissionAccess: [{ type: 'create_own', roles: ['real-role'] }], + owner: 'real-owner', + project: 'real-project', + components: [{ key: 'old' }], + settings: { keep: true }, + tags: ['old-tag'], + properties: { foo: 'old' }, + display: 'wizard', + }; + const draft = { + _id: 'STALE-ID', + title: 'STALE-TITLE', + revisions: 'STALE', + access: [], + submissionAccess: [], + owner: 'STALE-OWNER', + project: 'STALE-PROJECT', + components: [{ key: 'new' }], + settings: { keep: false }, + tags: ['new-tag'], + properties: { foo: 'new' }, + display: 'form', + }; + mockFormioFetch + .mockResolvedValueOnce(draft) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({}); + const { client } = await createTestClient(registerFormDraftPublishTool); + + await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall![3].body).toEqual({ + _id: FORM_ID, + title: 'keep', + name: 'keep', + path: 'keep', + revisions: 'original', + access: current.access, + submissionAccess: current.submissionAccess, + owner: 'real-owner', + project: 'real-project', + components: draft.components, + settings: draft.settings, + tags: draft.tags, + properties: draft.properties, + display: draft.display, + }); + }); + + it("strips draft's _vnote from publish body when caller does not supply note (matches portal behavior)", async () => { + const current = { + _id: FORM_ID, + revisions: 'original', + owner: 'o1', + components: [{ key: 'old' }], + }; + const draft = { + _id: FORM_ID, + _vnote: 'wip-draft-note', + components: [{ key: 'new' }], + }; + mockFormioFetch + .mockResolvedValueOnce(draft) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({}); + const { client } = await createTestClient(registerFormDraftPublishTool); + + await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall![3].body).not.toHaveProperty('_vnote'); + }); + + it("strips draft's _vnote and replaces with caller-supplied note (no concat, no leak)", async () => { + const current = { _id: FORM_ID, revisions: 'original', owner: 'o1', components: [] }; + const draft = { _id: FORM_ID, _vnote: 'old-draft-note', components: [{ key: 'new' }] }; + mockFormioFetch + .mockResolvedValueOnce(draft) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({}); + const { client } = await createTestClient(registerFormDraftPublishTool); + + await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, note: 'fresh-publish-note' }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall![3].body._vnote).toBe('fresh-publish-note'); + }); + + it('strips _vnote from supplied definition when caller does not supply note', async () => { + const definition = { + _id: FORM_ID, + revisions: 'original', + _vnote: 'leaked-from-definition', + components: [{ key: 'submit' }], + }; + mockFormioFetch.mockResolvedValueOnce({}); + const { client } = await createTestClient(registerFormDraftPublishTool); + + await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, definition }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall![3].body).not.toHaveProperty('_vnote'); + }); + + it('with note and no definition, attaches _vnote to merged publish body', async () => { + const current = { _id: FORM_ID, revisions: 'original', owner: 'o1', components: [] }; + const draft = { _id: FORM_ID, components: [] }; + mockFormioFetch + .mockResolvedValueOnce(draft) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({ ...current, ...draft, _vid: 2 }); + const { client } = await createTestClient(registerFormDraftPublishTool); + + await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, note: 'Added email field' }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall![3].body).toEqual(expect.objectContaining({ _vnote: 'Added email field' })); + }); + + it('with explicit definition, skips draft GET and PUTs supplied body', async () => { + const definition = { + _id: FORM_ID, + title: 'test5', + components: [{ type: 'email', key: 'email' }], + }; + mockFormioFetch.mockResolvedValueOnce({ ...definition, _vid: 2 }); + const { client } = await createTestClient(registerFormDraftPublishTool); + + await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, definition, note: 'with-def' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledTimes(1); + expect(mockFormioFetch).toHaveBeenCalledWith( + `form/${FORM_ID}`, + {}, + TEST_CONFIG, + expect.objectContaining({ + method: 'PUT', + body: expect.objectContaining({ ...definition, _vnote: 'with-def' }), + }) + ); + }); + + it('missing draft (404 on draft GET) surfaces toMcpError indicating no draft', async () => { + mockFormioFetch.mockRejectedValueOnce(new Error('Form.io API error: 404 Not Found')); + const { client } = await createTestClient(registerFormDraftPublishTool); + + const result = await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as { text: string }[])[0].text; + expect(text).toContain('404'); + }); + + it('preserves 409 publish conflict via toMcpError', async () => { + const draft = { _id: FORM_ID, components: [] }; + const current = { _id: FORM_ID, revisions: 'original', components: [] }; + mockFormioFetch + .mockResolvedValueOnce(draft) + .mockResolvedValueOnce(current) + .mockRejectedValueOnce(new Error('Form.io API error: 409 Conflict')); + const { client } = await createTestClient(registerFormDraftPublishTool); + + const result = await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ type: 'text', text: expect.stringContaining('409') }), + ]); + }); + + it('publish with no diff vs current published returns success (not error) — server no-op behavior', async () => { + const draft = { _id: FORM_ID, _vid: 1, components: [{ key: 'submit' }] }; + const current = { _id: FORM_ID, _vid: 1, components: [{ key: 'submit' }] }; + const noOpResponse = { _id: FORM_ID, _vid: 1, components: [{ key: 'submit' }] }; + mockFormioFetch + .mockResolvedValueOnce(draft) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(noOpResponse); + const { client } = await createTestClient(registerFormDraftPublishTool); + + const result = await client.callTool({ + name: 'form_draft_publish', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse((result.content as { text: string }[])[0].text); + expect(parsed._vid).toBe(1); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_revision_get.test.ts b/packages/mcp-server/src/__tests__/form_revision_get.test.ts new file mode 100644 index 0000000..f996e00 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revision_get.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormRevisionGetTool } = await import('../tools/form_revision_get.js'); + +describe('form_revision_get tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('is listed in available tools', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormRevisionGetTool); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain('form_revision_get'); + }); + + it('fetches a revision by sequential numeric version', async () => { + const id = '69f8f9ca71592601fd814e0f'; + const revision = { _id: 'r2', _vid: 2, _vnote: 'note', _vuser: 'admin' }; + mockFormioFetch.mockResolvedValue(revision); + const { client } = await createTestClient(registerFormRevisionGetTool); + + await client.callTool({ + name: 'form_revision_get', + arguments: { cwd: TEST_CWD, formIdOrPath: id, version: 2 }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${id}/v/2`, {}, TEST_CONFIG); + }); + + it('fetches a revision by string numeric version', async () => { + const id = '69f8f9ca71592601fd814e0f'; + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormRevisionGetTool); + + await client.callTool({ + name: 'form_revision_get', + arguments: { cwd: TEST_CWD, formIdOrPath: id, version: '3' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${id}/v/3`, {}, TEST_CONFIG); + }); + + it('fetches a revision by Mongo revision _id', async () => { + const id = '69f8f9ca71592601fd814e0f'; + const revisionId = '69f8f9d671592601fd814eb6'; + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormRevisionGetTool); + + await client.callTool({ + name: 'form_revision_get', + arguments: { cwd: TEST_CWD, formIdOrPath: id, version: revisionId }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${id}/v/${revisionId}`, {}, TEST_CONFIG); + }); + + it('resolves a path-style formIdOrPath to an _id first', async () => { + const formId = '69f8f9ca71592601fd814e0f'; + mockFormioFetch + .mockResolvedValueOnce({ _id: formId, name: 'test5' }) + .mockResolvedValueOnce({ _vid: 1 }); + const { client } = await createTestClient(registerFormRevisionGetTool); + + await client.callTool({ + name: 'form_revision_get', + arguments: { cwd: TEST_CWD, formIdOrPath: 'test5', version: 1 }, + }); + + expect(mockFormioFetch).toHaveBeenNthCalledWith(1, 'test5', {}, TEST_CONFIG); + expect(mockFormioFetch).toHaveBeenNthCalledWith(2, `form/${formId}/v/1`, {}, TEST_CONFIG); + }); + + it('returns the revision JSON as MCP text content', async () => { + const id = '69f8f9ca71592601fd814e0f'; + const revision = { _id: 'r1', _vid: 1, components: [] }; + mockFormioFetch.mockResolvedValue(revision); + const { client } = await createTestClient(registerFormRevisionGetTool); + + const result = await client.callTool({ + name: 'form_revision_get', + arguments: { cwd: TEST_CWD, formIdOrPath: id, version: 1 }, + }); + + expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(revision, null, 2) }]); + }); + + it('returns isError true with form + version on 404', async () => { + mockFormioFetch.mockRejectedValue(new Error('Form.io API error: 404 Not Found')); + const { client } = await createTestClient(registerFormRevisionGetTool); + + const result = await client.callTool({ + name: 'form_revision_get', + arguments: { + cwd: TEST_CWD, + formIdOrPath: '69f8f9ca71592601fd814e0f', + version: 99, + }, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ type: 'text', text: expect.stringContaining('404') }), + ]); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_revisions_list.test.ts b/packages/mcp-server/src/__tests__/form_revisions_list.test.ts new file mode 100644 index 0000000..84ebc5a --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revisions_list.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormRevisionsListTool } = await import('../tools/form_revisions_list.js'); + +describe('form_revisions_list tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('is listed in available tools', async () => { + mockFormioFetch.mockResolvedValue([]); + const { client } = await createTestClient(registerFormRevisionsListTool); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain('form_revisions_list'); + }); + + it('fetches revisions by MongoDB id using GET /form/{id}/v', async () => { + const id = '69f8f9ca71592601fd814e0f'; + const revisions = [ + { _id: 'r1', _rid: id, revisionId: 'r1', _vid: 1, _vnote: '', _vuser: 'admin' }, + { + _id: 'r2', + _rid: id, + revisionId: 'r2', + _vid: 2, + _vnote: 'publish-with-email', + _vuser: 'admin', + }, + ]; + mockFormioFetch.mockResolvedValue(revisions); + const { client } = await createTestClient(registerFormRevisionsListTool); + + await client.callTool({ + name: 'form_revisions_list', + arguments: { cwd: TEST_CWD, formIdOrPath: id }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${id}/v`, {}, TEST_CONFIG); + }); + + it('resolves a path to an id then calls /v', async () => { + const formId = '69f8f9ca71592601fd814e0f'; + const formByPath = { _id: formId, name: 'test5', path: 'test5' }; + const revisions = [ + { _id: 'r1', _rid: formId, revisionId: 'r1', _vid: 1, _vnote: '', _vuser: 'admin' }, + ]; + mockFormioFetch.mockResolvedValueOnce(formByPath).mockResolvedValueOnce(revisions); + const { client } = await createTestClient(registerFormRevisionsListTool); + + await client.callTool({ + name: 'form_revisions_list', + arguments: { cwd: TEST_CWD, formIdOrPath: 'test5' }, + }); + + expect(mockFormioFetch).toHaveBeenNthCalledWith(1, 'test5', {}, TEST_CONFIG); + expect(mockFormioFetch).toHaveBeenNthCalledWith(2, `form/${formId}/v`, {}, TEST_CONFIG); + }); + + it('returns a compact summary array (vid, modified, user, note)', async () => { + const id = '69f8f9ca71592601fd814e0f'; + const revisions = [ + { + _id: 'r1', + _rid: id, + revisionId: 'r1', + _vid: 1, + _vnote: '', + _vuser: 'admin', + modified: '2026-05-04T19:56:07.003Z', + components: [{ type: 'button', key: 'submit' }], + title: 'test5', + }, + ]; + mockFormioFetch.mockResolvedValue(revisions); + const { client } = await createTestClient(registerFormRevisionsListTool); + + const result = await client.callTool({ + name: 'form_revisions_list', + arguments: { cwd: TEST_CWD, formIdOrPath: id }, + }); + + const summaries = [{ vid: 1, modified: '2026-05-04T19:56:07.003Z', user: 'admin', note: '' }]; + expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(summaries, null, 2) }]); + }); + + it('returns isError true on 404', async () => { + mockFormioFetch.mockRejectedValue(new Error('Form.io API error: 404 Not Found')); + const { client } = await createTestClient(registerFormRevisionsListTool); + + const result = await client.callTool({ + name: 'form_revisions_list', + arguments: { cwd: TEST_CWD, formIdOrPath: '69f8f9ca71592601fd814e0f' }, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ type: 'text', text: expect.stringContaining('404') }), + ]); + }); + + it('matches the captured Form.io revision wire shape', async () => { + const id = '69f8f9ca71592601fd814e0f'; + const realisticRevisions = [ + { + _id: '69f8f9d671592601fd814eb6', + _rid: id, + revisionId: '69f8f9d671592601fd814eb6', + _vid: 1, + _vnote: '', + _vuser: 'admin', + modified: '2026-05-04T19:56:07.003Z', + title: 'test5', + name: 'test5', + path: 'test5', + components: [{ type: 'button', key: 'submit', label: 'Submit' }], + }, + { + _id: '69f8fa3071592601fd815100', + _rid: id, + revisionId: '69f8fa3071592601fd815100', + _vid: 2, + _vnote: 'publish-with-email', + _vuser: 'admin', + modified: '2026-05-04T19:58:35.905Z', + title: 'test5', + name: 'test5', + path: 'test5', + components: [ + { type: 'email', key: 'email', label: 'Email' }, + { type: 'button', key: 'submit', label: 'Submit' }, + ], + }, + ]; + mockFormioFetch.mockResolvedValue(realisticRevisions); + const { client } = await createTestClient(registerFormRevisionsListTool); + + const result = await client.callTool({ + name: 'form_revisions_list', + arguments: { cwd: TEST_CWD, formIdOrPath: id }, + }); + + const parsed = JSON.parse((result.content as { text: string }[])[0].text); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual({ + vid: 1, + modified: '2026-05-04T19:56:07.003Z', + user: 'admin', + note: '', + }); + expect(parsed[1]).toEqual({ + vid: 2, + modified: '2026-05-04T19:58:35.905Z', + user: 'admin', + note: 'publish-with-email', + }); + for (const rev of parsed) { + expect(rev).not.toHaveProperty('components'); + expect(rev).not.toHaveProperty('title'); + expect(rev).not.toHaveProperty('_id'); + expect(rev).not.toHaveProperty('_rid'); + expect(rev).not.toHaveProperty('revisionId'); + } + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_revisions_set.test.ts b/packages/mcp-server/src/__tests__/form_revisions_set.test.ts new file mode 100644 index 0000000..02f521b --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revisions_set.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormRevisionsSetTool } = await import('../tools/form_revisions_set.js'); + +const FORM_ID = '69f8f9ca71592601fd814e0f'; + +function baseForm(overrides: Record = {}) { + return { + _id: FORM_ID, + title: 'test5', + name: 'test5', + path: 'test5', + type: 'form', + components: [{ type: 'button', key: 'submit', label: 'Submit' }], + revisions: '', + submissionRevisions: '', + _vid: 0, + ...overrides, + }; +} + +describe('form_revisions_set tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('is listed in available tools', async () => { + mockFormioFetch.mockResolvedValue(baseForm()); + const { client } = await createTestClient(registerFormRevisionsSetTool); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain('form_revisions_set'); + }); + + it('tool description directs the agent to confirm with the user and covers all three modes', async () => { + mockFormioFetch.mockResolvedValue(baseForm()); + const { client } = await createTestClient(registerFormRevisionsSetTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_revisions_set'); + expect(tool).toBeDefined(); + expect(tool!.description).toMatch(/ask the user|confirm/i); + expect(tool!.description).toMatch(/current/i); + expect(tool!.description).toMatch(/original/i); + expect(tool!.description).toMatch(/disable|""/i); + }); + + it('with mode "current" on a non-revisioned form, GETs then PUTs revisions: "current" — exactly one PUT', async () => { + const form = baseForm({ revisions: '' }); + const updated = baseForm({ revisions: 'current', _vid: 1 }); + mockFormioFetch.mockResolvedValueOnce(form).mockResolvedValueOnce(updated); + const { client } = await createTestClient(registerFormRevisionsSetTool); + + await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: 'current' }, + }); + + expect(mockFormioFetch).toHaveBeenNthCalledWith(1, `form/${FORM_ID}`, {}, TEST_CONFIG); + expect(mockFormioFetch).toHaveBeenNthCalledWith( + 2, + `form/${FORM_ID}`, + {}, + TEST_CONFIG, + expect.objectContaining({ + method: 'PUT', + body: expect.objectContaining({ revisions: 'current' }), + }) + ); + const putCalls = mockFormioFetch.mock.calls.filter((c) => c[3]?.method === 'PUT'); + expect(putCalls).toHaveLength(1); + }); + + it('with mode "original", merges revisions: "original" into the body', async () => { + const form = baseForm(); + mockFormioFetch.mockResolvedValueOnce(form).mockResolvedValueOnce(form); + const { client } = await createTestClient(registerFormRevisionsSetTool); + + await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: 'original' }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall).toBeDefined(); + expect((putCall![3] as { body: { revisions: string } }).body.revisions).toBe('original'); + }); + + it('with mode "" on a revisioned form, PUTs revisions: "" to disable', async () => { + const form = baseForm({ revisions: 'original', _vid: 2 }); + const updated = baseForm({ revisions: '', _vid: 2 }); + mockFormioFetch.mockResolvedValueOnce(form).mockResolvedValueOnce(updated); + const { client } = await createTestClient(registerFormRevisionsSetTool); + + await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: '' }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect(putCall).toBeDefined(); + expect((putCall![3] as { body: { revisions: string } }).body.revisions).toBe(''); + const putCalls = mockFormioFetch.mock.calls.filter((c) => c[3]?.method === 'PUT'); + expect(putCalls).toHaveLength(1); + }); + + it('rejects invocation without mode (Zod validation error)', async () => { + const { client } = await createTestClient(registerFormRevisionsSetTool); + + const result = await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as { text: string }[])[0].text; + expect(text).toMatch(/mode/i); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + it('rejects invalid mode value', async () => { + const { client } = await createTestClient(registerFormRevisionsSetTool); + + const result = await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: 'bogus' }, + }); + + expect(result.isError).toBe(true); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + it('short-circuits when revisions already enabled with same mode (no PUT)', async () => { + const form = baseForm({ revisions: 'current', _vid: 1 }); + mockFormioFetch.mockResolvedValueOnce(form); + const { client } = await createTestClient(registerFormRevisionsSetTool); + + await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: 'current' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledTimes(1); + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${FORM_ID}`, {}, TEST_CONFIG); + }); + + it('short-circuits when already disabled and mode is "" (no PUT)', async () => { + const form = baseForm({ revisions: '' }); + mockFormioFetch.mockResolvedValueOnce(form); + const { client } = await createTestClient(registerFormRevisionsSetTool); + + await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: '' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledTimes(1); + const putCalls = mockFormioFetch.mock.calls.filter((c) => c[3]?.method === 'PUT'); + expect(putCalls).toHaveLength(0); + }); + + it('switches mode when current mode differs (issues PUT)', async () => { + const form = baseForm({ revisions: 'current', _vid: 1 }); + const updated = baseForm({ revisions: 'original', _vid: 2 }); + mockFormioFetch.mockResolvedValueOnce(form).mockResolvedValueOnce(updated); + const { client } = await createTestClient(registerFormRevisionsSetTool); + + await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: 'original' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledTimes(2); + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + expect((putCall![3] as { body: { revisions: string } }).body.revisions).toBe('original'); + }); + + it('initial GET 404 surfaces via toMcpError, no PUT issued', async () => { + mockFormioFetch.mockRejectedValue(new Error('Form.io API error: 404 Not Found')); + const { client } = await createTestClient(registerFormRevisionsSetTool); + + const result = await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: 'current' }, + }); + + expect(result.isError).toBe(true); + expect(mockFormioFetch).toHaveBeenCalledTimes(1); + const putCalls = mockFormioFetch.mock.calls.filter((c) => c[3]?.method === 'PUT'); + expect(putCalls).toHaveLength(0); + }); + + it('preserves upstream 402/403 license errors verbatim', async () => { + const form = baseForm(); + mockFormioFetch + .mockResolvedValueOnce(form) + .mockRejectedValueOnce(new Error('Form.io API error: 402 Payment Required')); + const { client } = await createTestClient(registerFormRevisionsSetTool); + + const result = await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: 'current' }, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ type: 'text', text: expect.stringContaining('402') }), + ]); + }); + + it('asserts wire shape: revisions field is the single string carrier', async () => { + const form = baseForm(); + mockFormioFetch.mockResolvedValueOnce(form).mockResolvedValueOnce(form); + const { client } = await createTestClient(registerFormRevisionsSetTool); + + await client.callTool({ + name: 'form_revisions_set', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, mode: 'current' }, + }); + + const putCall = mockFormioFetch.mock.calls.find((c) => c[3]?.method === 'PUT'); + const body = (putCall![3] as { body: Record }).body; + expect(body.revisions).toBe('current'); + expect(body.submissionRevisions).toBe(''); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_revisions_skill_ref.test.ts b/packages/mcp-server/src/__tests__/form_revisions_skill_ref.test.ts new file mode 100644 index 0000000..71370ed --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revisions_skill_ref.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const REFERENCE_PATH = resolve( + __dirname, + '../../../../plugin/skills/formio-api/references/project-form-revisions.md' +); + +describe('project-form-revisions.md MCP Tool Preference', () => { + const content = readFileSync(REFERENCE_PATH, 'utf8'); + + it('exists and is non-empty', () => { + expect(content.length).toBeGreaterThan(0); + }); + + it('names all six new MCP tools in the MCP Tool Preference section', () => { + const sectionMatch = content.match(/## MCP Tool Preference\s*\n([\s\S]*?)(?:\n##\s|$)/); + expect(sectionMatch).not.toBeNull(); + const section = sectionMatch![1]; + expect(section).toMatch(/form_revisions_list/); + expect(section).toMatch(/form_revision_get/); + expect(section).toMatch(/form_revisions_set/); + expect(section).toMatch(/form_draft_create/); + expect(section).toMatch(/form_draft_get/); + expect(section).toMatch(/form_draft_publish/); + }); + + it('does not still claim "no MCP tool covers this operation"', () => { + const sectionMatch = content.match(/## MCP Tool Preference\s*\n([\s\S]*?)(?:\n##\s|$)/); + const section = sectionMatch![1]; + expect(section).not.toMatch(/no mcp tool covers this operation/i); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_update.test.ts b/packages/mcp-server/src/__tests__/form_update.test.ts index 2ce1fa0..144c186 100644 --- a/packages/mcp-server/src/__tests__/form_update.test.ts +++ b/packages/mcp-server/src/__tests__/form_update.test.ts @@ -27,6 +27,26 @@ describe('form_update tool', () => { expect(tool!.description).toContain('formio-form'); }); + it('description routes revision-enabled forms to draft+publish tools', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormUpdateTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_update'); + expect(tool!.description).toMatch(/form_draft_create/); + expect(tool!.description).toMatch(/form_draft_publish/); + expect(tool!.description).toMatch(/revisions/i); + }); + + it('description forbids being used as fallback for failed draft/publish', async () => { + mockFormioFetch.mockResolvedValue({}); + const { client } = await createTestClient(registerFormUpdateTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_update'); + expect(tool!.description).toMatch(/never.*fallback|never.*fall.?back/i); + expect(tool!.description).toMatch(/form_draft_create|form_draft_publish/); + expect(tool!.description).toMatch(/surface/i); + }); + it('sends PUT to /form/{formId} with form body', async () => { const formId = '67890abcdef012345678abcd'; const updated = { _id: formId, title: 'Updated', components: [] }; diff --git a/packages/mcp-server/src/tools/form-draft-merge.ts b/packages/mcp-server/src/tools/form-draft-merge.ts new file mode 100644 index 0000000..a2b18bb --- /dev/null +++ b/packages/mcp-server/src/tools/form-draft-merge.ts @@ -0,0 +1,26 @@ +// Fields the Form.io portal overlays from a draft onto the current published +// form when saving a draft or publishing a revision. Mirrors the behavior in +// nirvana/apps/formio-app/src/scripts/controllers/form.js (draft-load + publish +// flows). Fields outside this set — `access`, `submissionAccess`, `revisions`, +// `owner`, `_id`, `project`, etc. — are preserved from the current published +// form and never overridden by the draft body. +export const DRAFT_OVERLAY_FIELDS = [ + 'components', + 'settings', + 'tags', + 'properties', + 'display', +] as const; + +export function mergeDraftOntoCurrent( + current: Record, + draft: Record +): Record { + const merged: Record = { ...current }; + for (const field of DRAFT_OVERLAY_FIELDS) { + if (field in draft) { + merged[field] = draft[field]; + } + } + return merged; +} diff --git a/packages/mcp-server/src/tools/form_draft_create.ts b/packages/mcp-server/src/tools/form_draft_create.ts new file mode 100644 index 0000000..1b6bccd --- /dev/null +++ b/packages/mcp-server/src/tools/form_draft_create.ts @@ -0,0 +1,60 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { formioFetch, isMongoId } from '../formio-client.js'; +import { toMcpTextResult, toMcpError } from '../mcp-responses.js'; +import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { mergeDraftOntoCurrent } from './form-draft-merge.js'; + +const TOOL_DESCRIPTION = [ + 'Save (or overwrite) the active draft revision of a Form.io form via PUT /form/:id/draft.', + 'A form has at most one active draft — this tool overwrites any existing draft.', + 'Default copies the current published form; pass `definition` to overlay draft-specific fields onto current.', + 'Optional `note` rides as `_vnote` on the saved draft row.', + 'Requires revisions enabled (see `form_revisions_set`).', + 'If this call fails (e.g., revisions disabled, license missing, auth error), DO NOT fall back to `form_update` — surface the error to the user. The user asked for a draft; silently overwriting the live form is not an acceptable substitute.', + 'See `project-form-revisions` skill reference for overlay-field list and edge cases.', +].join(' '); + +export function registerFormDraftCreateTool(server: McpServer, config: FormioConfig) { + server.tool( + 'form_draft_create', + TOOL_DESCRIPTION, + { + cwd: cwdSchema, + formIdOrPath: z + .string() + .describe('Form _id (24-char Mongo ObjectId) or path (e.g. "user/login")'), + definition: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Partial form JSON whose draft-specific fields (`components`, `settings`, `tags`, `properties`, `display`) overlay the current published form. Omit to copy the current published form into the draft slot unchanged.' + ), + note: z.string().optional().describe('Free-text note attached to this draft as `_vnote`'), + }, + async ({ cwd, formIdOrPath, definition, note }) => { + try { + const cfg = resolveProjectConfig(cwd, config); + const fetchPath = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; + const current = (await formioFetch(fetchPath, {}, cfg)) as Record; + const formId = (current._id as string) ?? formIdOrPath; + const baseBody = definition ? mergeDraftOntoCurrent(current, definition) : current; + const payload = note === undefined ? baseBody : { ...baseBody, _vnote: note }; + await formioFetch(`form/${formId}/draft`, {}, cfg, { + method: 'PUT', + body: payload, + }); + // Upstream `PUT /form/:id/draft` returns the pre-update document on + // overwrite (Mongoose `findOneAndUpdate` is missing `{ new: true }` in + // FormResource.putDraft). Re-fetch to return ground truth — mirrors + // what the portal does via a full page reload after each draft save. + // Remove once the upstream bug is fixed. + const saved = await formioFetch(`form/${formId}/draft`, {}, cfg); + return toMcpTextResult(saved); + } catch (error) { + return toMcpError(error); + } + } + ); +} diff --git a/packages/mcp-server/src/tools/form_draft_get.ts b/packages/mcp-server/src/tools/form_draft_get.ts new file mode 100644 index 0000000..b6bc08e --- /dev/null +++ b/packages/mcp-server/src/tools/form_draft_get.ts @@ -0,0 +1,31 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { formioFetch, isMongoId } from '../formio-client.js'; +import { toMcpTextResult, toMcpError } from '../mcp-responses.js'; +import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; + +export function registerFormDraftGetTool(server: McpServer, config: FormioConfig) { + server.tool( + 'form_draft_get', + 'Fetch the current active draft of a Form.io form (the mutable `_vid: "draft"` row in `formrevisions`). Use this when reading the in-progress draft body before editing or publishing it. For an immutable, numbered published revision, use `form_revision_get` instead. Returns 404 if the form has no saved draft or revisions are not enabled.', + { + cwd: cwdSchema, + formIdOrPath: z + .string() + .describe('Form _id (24-char Mongo ObjectId) or path (e.g. "user/login")'), + }, + async ({ cwd, formIdOrPath }) => { + try { + const cfg = resolveProjectConfig(cwd, config); + const formId = isMongoId(formIdOrPath) + ? formIdOrPath + : ((await formioFetch(formIdOrPath, {}, cfg)) as { _id: string })._id; + const draft = await formioFetch(`form/${formId}/draft`, {}, cfg); + return toMcpTextResult(draft); + } catch (error) { + return toMcpError(error); + } + } + ); +} diff --git a/packages/mcp-server/src/tools/form_draft_publish.ts b/packages/mcp-server/src/tools/form_draft_publish.ts new file mode 100644 index 0000000..7cafcdd --- /dev/null +++ b/packages/mcp-server/src/tools/form_draft_publish.ts @@ -0,0 +1,76 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { formioFetch, isMongoId } from '../formio-client.js'; +import { toMcpTextResult, toMcpError } from '../mcp-responses.js'; +import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { mergeDraftOntoCurrent } from './form-draft-merge.js'; + +const TOOL_DESCRIPTION = [ + 'Publish a Form.io form draft as the next revision via PUT /form/:id.', + 'Canonical path to add a new revision on a revision-enabled form: pair with `form_draft_create` (save proposed change as draft) then call this to publish.', + 'Default: fetches saved draft + current published form, overlays draft fields onto current, then publishes. Pass `definition` to publish a caller-supplied body directly.', + "Optional `note` becomes the new revision's `_vnote`. The draft's own `_vnote` is dropped — do NOT auto-forward it; draft and publish notes are independent.", + 'Server auto-clears the saved draft on success. No-op when the body matches the current published form.', + 'If this call fails (e.g., revisions disabled, license missing, auth error), DO NOT fall back to `form_update` — surface the error to the user. The user asked to publish a revision; silently overwriting the live form is not an acceptable substitute.', + 'See `project-form-revisions` skill reference for full overlay-field list and edge cases.', +].join(' '); + +export function registerFormDraftPublishTool(server: McpServer, config: FormioConfig) { + server.tool( + 'form_draft_publish', + TOOL_DESCRIPTION, + { + cwd: cwdSchema, + formIdOrPath: z + .string() + .describe('Form _id (24-char Mongo ObjectId) or path (e.g. "user/login")'), + definition: z + .record(z.string(), z.unknown()) + .optional() + .describe('Full form JSON to publish. Omit to publish the saved draft (the default).'), + note: z + .string() + .optional() + .describe( + "Free-text note attached to the published revision as `_vnote`. Provide ONLY if the user has explicitly stated what the published revision's note should be. Do NOT auto-populate from the draft's `_vnote` — draft notes and publish notes are independent. When in doubt, omit this argument." + ), + }, + async ({ cwd, formIdOrPath, definition, note }) => { + try { + const cfg = resolveProjectConfig(cwd, config); + const formId = isMongoId(formIdOrPath) + ? formIdOrPath + : ((await formioFetch(formIdOrPath, {}, cfg)) as { _id: string })._id; + + let baseBody: Record; + if (definition) { + baseBody = definition; + } else { + const [draft, current] = await Promise.all([ + formioFetch(`form/${formId}/draft`, {}, cfg) as Promise>, + formioFetch(`form/${formId}`, {}, cfg) as Promise>, + ]); + baseBody = mergeDraftOntoCurrent(current, draft); + } + // Match portal behavior: a draft's `_vnote` does NOT carry over to the + // published revision. The published revision's `_vnote` is only the + // value the caller explicitly passes via `note` here, mirroring how + // the portal collects "Revision notes" at publish time independently + // from any in-progress draft note. + const { _vnote: _strippedVnote, ...bodyWithoutDraftVnote } = baseBody; + void _strippedVnote; + const body = + note === undefined ? bodyWithoutDraftVnote : { ...bodyWithoutDraftVnote, _vnote: note }; + + const published = await formioFetch(`form/${formId}`, {}, cfg, { + method: 'PUT', + body, + }); + return toMcpTextResult(published); + } catch (error) { + return toMcpError(error); + } + } + ); +} diff --git a/packages/mcp-server/src/tools/form_revision_get.ts b/packages/mcp-server/src/tools/form_revision_get.ts new file mode 100644 index 0000000..0715573 --- /dev/null +++ b/packages/mcp-server/src/tools/form_revision_get.ts @@ -0,0 +1,34 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { formioFetch, isMongoId } from '../formio-client.js'; +import { toMcpTextResult, toMcpError } from '../mcp-responses.js'; +import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; + +export function registerFormRevisionGetTool(server: McpServer, config: FormioConfig) { + server.tool( + 'form_revision_get', + 'Fetch a Form.io form definition at a specific revision. The `version` argument accepts either a sequential `_vid` number (e.g. 2) or a revision document `_id` (24-char Mongo ObjectId).', + { + cwd: cwdSchema, + formIdOrPath: z + .string() + .describe('Form _id (24-char Mongo ObjectId) or path (e.g. "user/login")'), + version: z + .union([z.string(), z.number().int().nonnegative()]) + .describe('Sequential version number (`_vid`) or revision document `_id`'), + }, + async ({ cwd, formIdOrPath, version }) => { + try { + const cfg = resolveProjectConfig(cwd, config); + const formId = isMongoId(formIdOrPath) + ? formIdOrPath + : ((await formioFetch(formIdOrPath, {}, cfg)) as { _id: string })._id; + const revision = await formioFetch(`form/${formId}/v/${version}`, {}, cfg); + return toMcpTextResult(revision); + } catch (error) { + return toMcpError(error); + } + } + ); +} diff --git a/packages/mcp-server/src/tools/form_revisions_list.ts b/packages/mcp-server/src/tools/form_revisions_list.ts new file mode 100644 index 0000000..a34465c --- /dev/null +++ b/packages/mcp-server/src/tools/form_revisions_list.ts @@ -0,0 +1,56 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { formioFetch, isMongoId } from '../formio-client.js'; +import { toMcpTextResult, toMcpError } from '../mcp-responses.js'; +import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; + +interface RawRevision { + _vid?: number | string; + _vnote?: string; + _vuser?: string; + modified?: string; + [key: string]: unknown; +} + +interface RevisionSummary { + vid: number | string | undefined; + modified: string | undefined; + user: string | undefined; + note: string | undefined; +} + +function summarizeRevision(raw: RawRevision): RevisionSummary { + return { + vid: raw._vid, + modified: raw.modified, + user: raw._vuser, + note: raw._vnote, + }; +} + +export function registerFormRevisionsListTool(server: McpServer, config: FormioConfig) { + server.tool( + 'form_revisions_list', + 'List published revisions of a Form.io form. Returns a compact array of revision summaries: `vid` (sequential version), `modified` (ISO timestamp), `user` (publisher display name from `_vuser`), `note` (revision note from `_vnote`). Full form snapshots are intentionally omitted — use `form_revision_get` to fetch a specific revision\'s components and form definition. Does NOT include the active draft (drafts have `_vid: "draft"`); use `form_draft_get` to read the active draft.', + { + cwd: cwdSchema, + formIdOrPath: z + .string() + .describe('Form _id (24-char Mongo ObjectId) or path (e.g. "user/login")'), + }, + async ({ cwd, formIdOrPath }) => { + try { + const cfg = resolveProjectConfig(cwd, config); + const formId = isMongoId(formIdOrPath) + ? formIdOrPath + : ((await formioFetch(formIdOrPath, {}, cfg)) as { _id: string })._id; + const revisions = (await formioFetch(`form/${formId}/v`, {}, cfg)) as RawRevision[]; + const summaries = revisions.map(summarizeRevision); + return toMcpTextResult(summaries); + } catch (error) { + return toMcpError(error); + } + } + ); +} diff --git a/packages/mcp-server/src/tools/form_revisions_set.ts b/packages/mcp-server/src/tools/form_revisions_set.ts new file mode 100644 index 0000000..657f739 --- /dev/null +++ b/packages/mcp-server/src/tools/form_revisions_set.ts @@ -0,0 +1,57 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { formioFetch, isMongoId } from '../formio-client.js'; +import { toMcpTextResult, toMcpError } from '../mcp-responses.js'; +import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; + +const REVISION_MODE_DESCRIPTION = + 'Required. `revisions` field value: "current" or "original" enables (display mode for historical submissions); "" disables. Ask the user — no default.'; + +const TOOL_DESCRIPTION = [ + 'Set the Form.io form `revisions` field via PUT /form/:id. Use to enable, switch display mode, or disable revisions.', + '`mode` is REQUIRED ("current" | "original" | ""). Ask the user — no default.', + '"" disables (server preserves `_vid` and existing revision rows). No-op when form already has the requested mode.', + 'See `project-form-revisions` skill reference for mode semantics.', +].join(' '); + +interface FormDoc { + _id: string; + revisions?: string; + [key: string]: unknown; +} + +export function registerFormRevisionsSetTool(server: McpServer, config: FormioConfig) { + server.tool( + 'form_revisions_set', + TOOL_DESCRIPTION, + { + cwd: cwdSchema, + formIdOrPath: z + .string() + .describe('Form _id (24-char Mongo ObjectId) or path (e.g. "user/login")'), + mode: z.enum(['current', 'original', '']).describe(REVISION_MODE_DESCRIPTION), + }, + async ({ cwd, formIdOrPath, mode }) => { + try { + const cfg = resolveProjectConfig(cwd, config); + const fetchPath = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; + const form = (await formioFetch(fetchPath, {}, cfg)) as FormDoc; + + const current = form.revisions ?? ''; + if (current === mode) { + return toMcpTextResult(form); + } + + const merged = { ...form, revisions: mode }; + const updated = await formioFetch(`form/${form._id}`, {}, cfg, { + method: 'PUT', + body: merged, + }); + return toMcpTextResult(updated); + } catch (error) { + return toMcpError(error); + } + } + ); +} diff --git a/packages/mcp-server/src/tools/form_update.ts b/packages/mcp-server/src/tools/form_update.ts index 95d521d..0508222 100644 --- a/packages/mcp-server/src/tools/form_update.ts +++ b/packages/mcp-server/src/tools/form_update.ts @@ -8,7 +8,7 @@ import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; export function registerFormUpdateTool(server: McpServer, config: FormioConfig) { server.tool( 'form_update', - "Update an existing form in the Form.io project mapped to the user's current working directory. IMPORTANT: Before calling this tool, first use form_get to fetch the current form definition, then use the formio-form skill to apply the requested modifications (add, remove, or modify fields and settings), and finally call this tool with the complete updated form JSON.", + 'Update an existing form in the Form.io project mapped to the user\'s current working directory. IMPORTANT: Before calling this tool, first use form_get to fetch the current form definition, then use the formio-form skill to apply the requested modifications (add, remove, or modify fields and settings), and finally call this tool with the complete updated form JSON. DO NOT use for adding a revision: if the form has `revisions: "current"` or `"original"`, use `form_draft_create` then `form_draft_publish` instead. NEVER use as a fallback when `form_draft_create` or `form_draft_publish` fails — surface those errors to the user.', { cwd: cwdSchema, formId: z diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index d2c200b..a1cf5cb 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -8,8 +8,14 @@ import { registerActionTypeGetTool } from './action_type_get.js'; import { registerActionTypesListTool } from './action_types_list.js'; import { registerActionUpdateTool } from './action_update.js'; import { registerFormCreateTool } from './form_create.js'; +import { registerFormDraftCreateTool } from './form_draft_create.js'; +import { registerFormDraftGetTool } from './form_draft_get.js'; +import { registerFormDraftPublishTool } from './form_draft_publish.js'; import { registerFormGetTool } from './form_get.js'; import { registerFormListTool } from './form_list.js'; +import { registerFormRevisionGetTool } from './form_revision_get.js'; +import { registerFormRevisionsListTool } from './form_revisions_list.js'; +import { registerFormRevisionsSetTool } from './form_revisions_set.js'; import { registerFormUpdateTool } from './form_update.js'; import { registerHelloTool } from './hello.js'; import { registerProjectExportTool } from './project_export.js'; @@ -30,8 +36,14 @@ export function registerAllTools( ) { registerHelloTool(server); registerFormCreateTool(server, config); + registerFormDraftCreateTool(server, config); + registerFormDraftGetTool(server, config); + registerFormDraftPublishTool(server, config); registerFormGetTool(server, config); registerFormListTool(server, config); + registerFormRevisionGetTool(server, config); + registerFormRevisionsListTool(server, config); + registerFormRevisionsSetTool(server, config); registerFormUpdateTool(server, config); registerProjectExportTool(server, config); registerProjectImportTool(server, config); diff --git a/plugin/skills/formio-api/references/project-form-revisions.md b/plugin/skills/formio-api/references/project-form-revisions.md index 01b47a7..5df340f 100644 --- a/plugin/skills/formio-api/references/project-form-revisions.md +++ b/plugin/skills/formio-api/references/project-form-revisions.md @@ -3,6 +3,17 @@ The Form Revisions API gives a project admin a draft/publish workflow for form definitions. Once revisions are enabled on a form, every published change creates a new numbered revision; admins can work on a draft that does not yet affect submissions, publish that draft to create the next revision, and retrieve any historical revision by its version number or revision ID. This skill covers enabling revisions, drafting, publishing, listing revisions, and fetching a specific revision. For non-revisioned form CRUD, see `project-forms.md`. +**Display modes (set via `revisions` on the form doc):** + +- `"current"` — historical submissions render in the *latest* form revision. +- `"original"` — each submission renders in the revision it was captured under. +- `""` (empty string) — revisions disabled. Default for new forms. + +**Storage model:** + +- Form documents live in the `forms` collection. The active revision pointer is the integer `_vid` field on the form doc; the mode is the string `revisions` field. +- Published revisions AND the single active draft live in the `formrevisions` collection — one row per revision plus at most one row per form with `_vid: "draft"` (string sentinel, not an integer). Each row carries `_id`, `_rid` (parent form `_id`), `revisionId` (== `_id`), `_vid`, `_vnote` (note), `_vuser` (publisher's display name, server-set from JWT), `modified`, plus the full form snapshot. Drafts are NOT a separate collection. + ## Root URL All endpoints below are rooted at `${FORMIO_PROJECT_URL}` — the project endpoint, equivalent to `{{baseUrl}}/{{projectName}}` in Postman. @@ -13,19 +24,30 @@ Every request to these endpoints MUST include an `x-jwt-token` header holding th ## MCP Tool Preference -No MCP tool covers this operation — use the HTTP endpoint directly. +Prefer the first-party MCP tools below over raw HTTP for the operations they cover. The portal-login JWT auth flow described above is applied automatically by these tools. + +- `form_revisions_list` — `GET ${FORMIO_PROJECT_URL}/form/:formId/v` (list published revisions as compact summaries — each entry has `vid`, `modified`, `user`, `note`; full form snapshots are stripped to keep the response small. Use `form_revision_get` for a single revision's full body.) +- `form_revision_get` — `GET ${FORMIO_PROJECT_URL}/form/:formId/v/:version` (fetch a form at a specific `_vid` or revision `_id`) +- `form_revisions_set` — `PUT ${FORMIO_PROJECT_URL}/form/:formId` to set the form's `revisions` field. Required `mode: "current" | "original" | ""` with no default — ask the user which value to set. `"current"` and `"original"` enable revisions (display mode for historical submissions); `""` disables (server preserves `_vid` and existing `formrevisions` rows). +- `form_draft_create` — `PUT ${FORMIO_PROJECT_URL}/form/:formId/draft` then a follow-up `GET ${FORMIO_PROJECT_URL}/form/:formId/draft` (save or overwrite the single active draft; optional `note` rides as `_vnote`. The post-PUT GET is a workaround for an upstream Mongoose `findOneAndUpdate` bug that returns the pre-update document — the GET returns ground truth.) +- `form_draft_get` — `GET ${FORMIO_PROJECT_URL}/form/:formId/draft` (fetch the current active draft — the mutable `_vid: "draft"` row; use `form_revision_get` instead for an immutable numbered revision) +- `form_draft_publish` — `PUT ${FORMIO_PROJECT_URL}/form/:formId` (promote the saved draft, or a caller-supplied `definition`, to the next published revision; optional `note` rides as `_vnote`; server auto-clears the draft on success; no-op when the body matches the current published form). The tool strips any `_vnote` from the publish body before the PUT — the published revision's `_vnote` is set EXCLUSIVELY by the explicit `note` argument (matching portal behavior). Do NOT auto-forward the draft's `_vnote` as `note` — draft notes and publish notes are independent. ## Endpoints ### PUT ${FORMIO_PROJECT_URL}/form/:formId -Enable form revisions on an existing form by setting `revisions` on the form document. This endpoint is the standard form update (see `project-forms.md`), but when a form is saved with `revisions` turned on, subsequent draft/publish operations become available on that form. Once enabled, every `PUT` to this path creates a new published revision. +Enable form revisions on an existing form by setting `revisions` on the form document, and (once revisions are on) publish a new revision. This endpoint is the standard form update (see `project-forms.md`); whether a `PUT` enables revisions, publishes a new revision, or no-ops depends on the body and the form's current state: + +- **Enable**: when the form's current `revisions` is `""` and the body sets `revisions: "current"` or `"original"`, the same `PUT` flips the mode AND inserts the first `formrevisions` row at `_vid: 1`. No separate seed call is needed — the server handles both writes. +- **Publish (with diff)**: when `revisions` is already on, a `PUT` whose body differs from the current published form (ignoring `_vnote`) bumps `_vid` and inserts a new numeric `formrevisions` row. The server also removes the existing `_vid: "draft"` row automatically. +- **Publish (no diff)**: when the body matches the current published form, the server returns 200 with the form unchanged. No new revision row, no error. The `_vid: "draft"` row, if present, survives. | Path parameter | Type | Description | | --- | --- | --- | | `formId` | string | The MongoDB `_id` of the form to update. | -Request body (JSON): the full form definition, including `_id`, `title`, `name`, `path`, `type`, `display`, `components`, and (to enable the feature) a `revisions` flag per the form's settings. +Request body (JSON): the full form definition, including `_id`, `title`, `name`, `path`, `type`, `display`, `components`, plus a `revisions` field (`"current"` / `"original"` / `""`) and an optional top-level `_vnote` string. The `_vnote` field rides on the publish body and lands on the new `formrevisions` row; the response strips it (not echoed back). `_vuser` is server-set from the JWT and MUST NOT be sent by the caller. ```json { @@ -59,13 +81,15 @@ curl -X PUT -H "x-jwt-token: $FORMIO_JWT" -H "Content-Type: application/json" \ ### PUT ${FORMIO_PROJECT_URL}/form/:formId/draft -Save an in-progress draft of a revisioned form. Drafts do not affect live submissions; they are a working copy that the admin iterates on until ready to publish. +Save (or overwrite) the active draft of a revisioned form. Drafts do not affect live submissions; they are a working copy that the admin iterates on until ready to publish. **A form has at most one active draft at a time** — calling this endpoint overwrites any existing draft. + +The draft is stored as a single row in the `formrevisions` collection with `_vid: "draft"` (string sentinel, not a number). The form doc is NOT touched by this call; only the draft row is upserted. | Path parameter | Type | Description | | --- | --- | --- | | `formId` | string | The MongoDB `_id` of the form whose draft is being updated. | -Request body (JSON): same shape as the published form body — `_id`, `title`, `name`, `path`, `type`, `display`, `components`, etc. +Request body (JSON): same shape as the published form body — `_id`, `title`, `name`, `path`, `type`, `display`, `components`, etc. An optional top-level `_vnote` string is persisted on the draft row (drafts carry their own note, distinct from any future publish note). Response: the saved draft document (same shape as a form document). Subsequent calls to `GET .../draft` will return this state until publish or overwrite. @@ -98,37 +122,32 @@ curl -H "x-jwt-token: $FORMIO_JWT" \ "${FORMIO_PROJECT_URL}/form/69d69ce1040fa2cea2572c71/draft" ``` -### PUT ${FORMIO_PROJECT_URL}/form/:formId - -Publish a draft. Publishing is done by issuing a standard `PUT` to the form endpoint with the desired definition — Form.io treats that save as the next published revision when revisions are enabled. After publishing, the draft is cleared and the new revision is appended to the revision list. - -| Path parameter | Type | Description | -| --- | --- | --- | -| `formId` | string | The MongoDB `_id` of the form being published. | - -Request body (JSON): the full form definition to publish. Typically the admin fetches `GET .../draft`, makes any last edits, then `PUT`s that body here. +### Publishing a revision -Response: the published form document with an incremented `_vid` and a new entry in `GET .../v`. +Publishing is NOT a distinct endpoint — it is the same `PUT ${FORMIO_PROJECT_URL}/form/:formId` documented above. When revisions are enabled and the body differs from the current published form, the `PUT` is a publish: it bumps `_vid`, inserts a new numeric `formrevisions` row, and removes the active `_vid: "draft"` row. Publishing does NOT auto-fetch the saved draft — the body sent in the `PUT` is what gets published. The portal UI fetches `GET .../draft` first and sends that body. -Errors: `400` for validation errors; `404` if the form does not exist; `409` on `_vid` conflicts; `401`/`403` as above. - -Example: +Attach a top-level `_vnote` string to the publish body to carry a revision note onto the new `formrevisions` row. The server strips `_vnote` from the response. -```bash -curl -X PUT -H "x-jwt-token: $FORMIO_JWT" -H "Content-Type: application/json" \ - -d @published.json \ - "${FORMIO_PROJECT_URL}/form/69d69ce1040fa2cea2572c71" -``` +When the body matches the current published form (ignoring `_vnote`), the publish is a no-op: server returns 200, no new revision row is created, the existing draft survives. ### GET ${FORMIO_PROJECT_URL}/form/:formId/v -List every revision of a form, ordered oldest to newest. Each revision is a full snapshot of the form definition at publish time. +List every published revision of a form. Each entry is a full snapshot of the form definition at publish time. The active draft (`_vid: "draft"`) is NOT included in this listing — fetch it via `GET .../draft`. | Path parameter | Type | Description | | --- | --- | --- | | `formId` | string | The MongoDB `_id` of the form. | -Response: JSON array of form-revision documents. Each entry includes `_id` (revision ID, distinct from the form's `_id`), `title`, `name`, `path`, `type`, `display`, `tags`, `access`, `submissionAccess`, `owner`, and `components`. The revision's sequential version number is implied by array order and by the URL used to retrieve it. +Response: JSON array of form-revision documents. Each entry includes: + +- `_id` — revision document `_id` (distinct from the form's `_id`) +- `_rid` — parent form `_id` (back-reference to the `forms` collection) +- `revisionId` — equal to `_id` (server-managed alias) +- `_vid` — sequential version integer (`1`, `2`, ...) +- `_vnote` — revision note captured at publish time (empty string when none provided) +- `_vuser` — display name of the publisher (server-set from the JWT, e.g. `"admin"`) +- `modified` — ISO-8601 timestamp of the publish +- The full form snapshot: `title`, `name`, `path`, `type`, `display`, `tags`, `access`, `submissionAccess`, `owner`, `components` Errors: `404` if the form does not exist or has no revisions; `401`/`403` as above.