diff --git a/openspec/changes/mcp-form-revisions/.openspec.yaml b/openspec/changes/mcp-form-revisions/.openspec.yaml new file mode 100644 index 0000000..243884d --- /dev/null +++ b/openspec/changes/mcp-form-revisions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-tdd +created: 2026-05-21 diff --git a/openspec/changes/mcp-form-revisions/design.md b/openspec/changes/mcp-form-revisions/design.md new file mode 100644 index 0000000..b14a9cc --- /dev/null +++ b/openspec/changes/mcp-form-revisions/design.md @@ -0,0 +1,57 @@ +## Context + +Form.io exposes a draft/publish/revert revision lifecycle on deployments licensed for the Security Module. Once `revisions` is enabled on a form, every standard `PUT /form/:id` also creates a new revision automatically — the draft/publish flow is optional, but day-to-day history tracking happens on standard updates. The MCP server previously only wrapped the flat `PUT /form/:id`, so LLMs could not stage edits, list history, or roll back, and had no way to surface either gating decision to the user (deployment-level license, per-form tracking mode). Standard updates against a revisions-disabled form also silently skipped history with no user-facing decision point. + +## Goals / Non-Goals + +**Goals:** + +- Expose draft/publish/revert via `form_update` flags and add list/get tools for revisions. +- Block draft/publish/revert on unlicensed deployments with a clear error; offer one-time "continue without revision tracking" consent for standard writes. +- Force a per-form decision when a stored form has revisions disabled, instead of silently mirroring the disabled state. +- Persist every write's `_vnote` with a `@formio/mcp:` prefix so revision history is attributable on every path that produces a revision — standard updates (when `revisions` is enabled), drafts, publishes, reverts. + +**Non-Goals:** + +- No backward-compat shim for callers that previously passed `revisions: ''` to silently no-op the prompt — the gate now fires. +- No general-purpose UI for picking revisions; the browser fallback exists only because some MCP clients do not yet implement elicitation. + +## Decisions + +### Two distinct gates, in that order + +1. **License gate** (`gateRevisionsLicense`) — keyed on `baseUrl`. Throws for draft/publish/revert when unlicensed; otherwise prompts once per `baseUrl` and persists positive consent in `~/.formio/revisions-license-consent.json`. Returns `{ licensed, form }` with `revisions` stripped when unlicensed. +2. **Per-form tracking gate** (`gateRevisionsTracking`) — keyed on `formId`. Runs only on standard `form_update` against licensed deployments when the stored form has `revisions` disabled and the caller did not opt in via `revisions: 'original'|'current'`. `revisions: ''` does NOT count as opt-in (would let an LLM auto-skip the audit-trail decision). Approval to "proceed without history" is session-scoped (`Set` in-memory). + +**Rationale:** the two gates ask different questions ("is this deployment capable of revisions" vs. "for THIS form, how do you want them tracked"). Conflating them either silently strips revisions on every write or re-prompts forever. The split also lets `form_create` skip the tracking gate (there is no stored form yet). + +### Prompt transport: elicitation with a browser fallback + +When MCP elicitation is available, the gate uses it. Otherwise it spins up a local Express server on an ephemeral 127.0.0.1 port, renders a styled consent page, opens the user's browser, and resolves on the `/callback` POST. The fallback exists only because some clients have not shipped elicitation yet; both gates have a `// TEMPORARY` marker so we remove the browser path when we no longer need it. + +### Field allowlists for draft / publish / revert + +- Draft PUT body fields: `components`, `settings`, `tags`, `properties`, `controller`, `esign`, `display`. Non-allowlisted fields cause `saveDraft` to throw — anything outside the set is either silently dropped on publish (footgun) or has no effect on a draft (server-managed metadata). +- Publish overlays the live form with the draft's allowlist; caller `form` is ignored entirely so identity (title/name/path/access) is never silently rewritten by a publish. +- Revert overlays the live form with the revision's revert allowlist (`components`, `tags`, `properties`, `display`) — narrower than publish because reverts are rollback-only, not full revision restoration. +- Standard updates are not affected + +### `revisions: 'original'` default on form_create + +New forms default to `revisions: 'original'` on licensed deployments so submission history is preserved by default — every subsequent standard `form_update` then produces a revision automatically, no draft/publish step required. Callers can override (`'current'`, `''`). On unlicensed deployments the field is stripped (the API would write it, but can't honor it). + +### Standard updates are the primary history-tracking path + +Draft/publish/revert exist for staged workflows, but the day-to-day history-tracking contract is: enable `revisions` on the form (default for new forms, opt-in via the per-form gate for existing ones), then every `form_update` PUT creates a revision server-side with the caller's `note` attached as `_vnote`. The two gates above exist to make sure that path is opted into deliberately — once opted in, no further prompts fire on subsequent updates to the same form. + +## Risks / Trade-offs + +- **Browser fallback opens an actual browser tab** — fine in interactive sessions, awkward in headless ones. The license gate is one-time per `baseUrl`; the tracking gate is one prompt per new form. We accept the friction. +- **Express dependency** adds a transitive footprint to the MCP server. Pinned to the consent-page use case; remove when elicitation is universal. +- **In-process license cache (`revisionsLicensedByBaseUrl`)** does not re-check `/config.js` across the lifetime of a process. If a deployment flips its Security Module state mid-session, the cached value lags until the MCP server restarts. Acceptable — license state is administrative and rare. + +## Migration Plan + +No data migration. Existing `.mcp.json` configs continue to work. First standard write against an unlicensed deployment prompts the user once and writes the consent file; first standard write against a licensed deployment for a form with revisions disabled prompts once per form. + +Rollback: revert the PR; delete `~/.formio/revisions-license-consent.json` if undesired state lingers. diff --git a/openspec/changes/mcp-form-revisions/proposal.md b/openspec/changes/mcp-form-revisions/proposal.md new file mode 100644 index 0000000..f2fdce4 --- /dev/null +++ b/openspec/changes/mcp-form-revisions/proposal.md @@ -0,0 +1,34 @@ +## Why + +Form.io supports a draft → publish → revert revision lifecycle on licensed deployments, but the MCP server only exposed a flat PUT. Once `revisions` is enabled on a form, every standard `PUT /form/:id` also creates a new revision automatically — the draft/publish flow is optional, but day-to-day history tracking happens on standard updates. LLMs had no way to stage edits, list history, roll back, surface the "Security Module required" license condition, or surface the per-form revision-mode decision to the user. Result: silent loss of audit history on unlicensed deployments, no way to drive Form.io's revisions workflow from chat, and standard updates against a revisions-disabled form silently skipped history with no user-facing decision point. + +## What Changes + +- **NEW** `form_revisions_list` — `GET /form/:id/v`, returns revision summaries. +- **NEW** `form_revision_get` — `GET /form/:id/v/:version`, returns a single revision body. +- **`form_update`** gains `draft`, `publish`, `revert` (mutually exclusive), `version` (for `revert`), and required `note`. Drafts/publishes/reverts use field allowlists; `note` is persisted as `_vnote` with `@formio/mcp:` prefix on every write — including standard PUTs, which create a new revision server-side whenever the stored form has `revisions` enabled. +- **`form_get`** gains `draft: true` to fetch the in-flight draft (errors when no draft exists). +- **`form_create`** defaults `revisions: 'original'` on licensed deployments; strips `revisions` when unlicensed. +- **License gate** — once per `baseUrl`, prompts the user to "continue without revision tracking" when the Security Module is absent. Positive consent persists in `~/.formio/revisions-license-consent.json`. `draft`/`publish`/`revert` throw on unlicensed deployments. +- **Per-form tracking gate** — on standard `form_update` against a licensed deployment, when the stored form has `revisions` disabled and the caller did not opt in via `revisions: 'original'|'current'`, prompts the user to enable revisions or proceed without history. `revisions: ''` does NOT bypass the prompt. Per-form approvals are session-scoped. +- Both gates prompt via MCP elicitation when the client supports it; fall back to a local browser consent page otherwise. + +## Capabilities + +### New Capabilities + +- `form-revisions` + +### Modified Capabilities + +- `form-create` +- `form-update` +- `form-get` + +## Impact + +- New tools: `form_revisions_list`, `form_revision_get` (registered in `tools/index.ts`). +- New module: `src/revisions/` (license gate, tracking gate, draft/publish/revert flows, browser fallback prompts, helpers). +- Touched tools: `form_create`, `form_update`, `form_get`. +- New on-disk file: `~/.formio/revisions-license-consent.json` (mode 0600). +- Reference doc updated: `plugin/skills/formio-api/references/project-form-revisions.md`. \ No newline at end of file diff --git a/openspec/changes/mcp-form-revisions/specs/form-create/spec.md b/openspec/changes/mcp-form-revisions/specs/form-create/spec.md new file mode 100644 index 0000000..1aca0d5 --- /dev/null +++ b/openspec/changes/mcp-form-revisions/specs/form-create/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: form_create defaults revisions to 'original' on licensed deployments + +On a licensed deployment, `form_create` SHALL default the POST body to `revisions: 'original'` when the caller does not specify `revisions`. A caller-supplied `revisions` value SHALL override the default. On an unlicensed deployment, `revisions` SHALL be stripped from the body and the license gate SHALL prompt once per `baseUrl`. + +#### Scenario: Licensed default + +- **WHEN** `form_create` is called on a licensed deployment with `form: { title, name, path, components: [] }` (no `revisions`) +- **THEN** the POST body contains `revisions: 'original'` + +#### Scenario: Caller override + +- **WHEN** `form_create` is called with `form: { ..., revisions: 'current' }` on a licensed deployment +- **THEN** the POST body contains `revisions: 'current'` + +#### Scenario: Unlicensed strips revisions + +- **WHEN** `form_create` is called on an unlicensed deployment after the user consents to continue +- **THEN** the POST body does not include `revisions` + +### Requirement: form_create persists the note as _vnote + +When `note` is provided, `form_create` SHALL include `_vnote` in the POST body prefixed with `@formio/mcp:`. + +#### Scenario: Note prefixed + +- **WHEN** `form_create` is called with `note: "initial"` +- **THEN** the POST body's `_vnote` equals `@formio/mcp: initial` diff --git a/openspec/changes/mcp-form-revisions/specs/form-get/spec.md b/openspec/changes/mcp-form-revisions/specs/form-get/spec.md new file mode 100644 index 0000000..a032b2f --- /dev/null +++ b/openspec/changes/mcp-form-revisions/specs/form-get/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: form_get supports fetching the current draft + +`form_get` SHALL accept an optional `draft: true` parameter. When set, the tool SHALL request `/{base}/draft` (where `{base}` is `form/{id}` for a Mongo ObjectId or the path alias). The endpoint falls back to the live form when no draft exists; the tool SHALL detect this by checking `_vid === 'draft'` on the response and SHALL throw a "no draft exists" error otherwise. + +#### Scenario: Fetch existing draft + +- **WHEN** `form_get` is called with `formIdOrPath: "67890abcdef012345678abcd"` and `draft: true` and the response has `_vid: 'draft'` +- **THEN** the tool returns the draft body as MCP text content + +#### Scenario: No draft exists + +- **WHEN** `form_get` is called with `draft: true` and the response has `_vid !== 'draft'` +- **THEN** the tool throws an error instructing the caller to create one via `form_update` with `draft: true` diff --git a/openspec/changes/mcp-form-revisions/specs/form-revisions/spec.md b/openspec/changes/mcp-form-revisions/specs/form-revisions/spec.md new file mode 100644 index 0000000..bb2db08 --- /dev/null +++ b/openspec/changes/mcp-form-revisions/specs/form-revisions/spec.md @@ -0,0 +1,122 @@ +## ADDED Requirements + +### Requirement: form_revisions_list returns revision summaries + +The `form_revisions_list` tool SHALL call `GET /form/{id}/v` (or `GET /{alias}/v` for path aliases) and return the response as MCP text content. + +#### Scenario: List by form id + +- **WHEN** `form_revisions_list` is called with `formIdOrPath: "67890abcdef012345678abcd"` +- **THEN** it requests `/form/67890abcdef012345678abcd/v` +- **AND** returns the revision list as MCP text content + +### Requirement: form_revision_get returns a single revision + +The `form_revision_get` tool SHALL call `GET /form/{id}/v/{version}` where `version` is either a sequential `_vid` or a 24-char revision document `_id`, and return the response as MCP text content. + +#### Scenario: Get revision by vid + +- **WHEN** `form_revision_get` is called with `formIdOrPath: "67890abcdef012345678abcd"` and `version: "3"` +- **THEN** it requests `/form/67890abcdef012345678abcd/v/3` +- **AND** returns the revision body as MCP text content + +### Requirement: License gate blocks draft/publish/revert on unlicensed deployments + +When the deployment's `/config.js` does not advertise `sac = true`, `form_update` with any of `draft`, `publish`, `revert` SHALL throw without prompting and without calling the API. License status SHALL be cached per `baseUrl`. + +#### Scenario: Unlicensed deployment rejects draft + +- **WHEN** `form_update` is called with `draft: true` against a deployment whose `/config.js` reports `sac = false` +- **THEN** the tool throws an error instructing the caller to drop the flag and call `form_update` as a standard update +- **AND** no PUT request is sent + +### Requirement: License gate prompts once for standard writes on unlicensed deployments + +On an unlicensed deployment, `form_create` and standard `form_update` SHALL prompt the user once per `baseUrl` to continue without revision tracking. Positive consent SHALL persist to `~/.formio/revisions-license-consent.json` (mode 0600) and SHALL be cached in-memory thereafter. Cancel SHALL throw a user-cancelled error and SHALL NOT persist. + +#### Scenario: User cancels the license gate + +- **WHEN** the gate prompts and the user chooses "cancel" +- **THEN** the tool throws a USER CANCELLED error +- **AND** no API request is sent +- **AND** no consent is written to disk + +#### Scenario: Cached consent skips the prompt + +- **WHEN** `~/.formio/revisions-license-consent.json` already records `true` for the current `baseUrl` +- **THEN** the gate proceeds without prompting + +### Requirement: Per-form tracking gate prompts when revisions are off + +On a licensed deployment, a standard `form_update` (no `draft`/`publish`/`revert`) against a stored form whose `revisions` is falsy SHALL prompt the user with three choices — enable revisions (original), enable revisions (current), or proceed without history — UNLESS the caller opted in via `revisions: 'original'|'current'` on the body, OR the user already approved "proceed without history" for that `formId` in the current process. Passing `revisions: ''` SHALL NOT bypass the prompt. On cancel, the tool SHALL throw and no PUT SHALL be sent. + +#### Scenario: Caller opted in via revisions: 'current' + +- **WHEN** `form_update` is called with `form: { ..., revisions: 'current' }` against a form with revisions disabled +- **THEN** no prompt is shown +- **AND** the PUT body contains `revisions: 'current'` + +#### Scenario: Caller passes revisions: '' on a disabled form + +- **WHEN** `form_update` is called with `form: { ..., revisions: '' }` against a form with revisions disabled +- **THEN** the per-form tracking gate prompts the user + +#### Scenario: User chooses enable revisions (original) + +- **WHEN** the gate prompts and the user chooses "enable-original" +- **THEN** the PUT body contains `revisions: 'original'` + +#### Scenario: User chooses proceed without history + +- **WHEN** the gate prompts and the user chooses "proceed-without-history" +- **THEN** any caller-supplied `revisions` is stripped from the PUT body +- **AND** subsequent `form_update` calls for the same `formId` in this process do not re-prompt + +### Requirement: Standard updates create a new revision when revisions are enabled + +When a stored form has `revisions` set to `'original'` or `'current'`, every standard `form_update` (no `draft`/`publish`/`revert`) SHALL create a new revision server-side via the `PUT /form/:id` call — the draft/publish flow is not required for history tracking. The PUT body SHALL include `_vnote` prefixed with `@formio/mcp:` so the new revision carries the caller's note. + +#### Scenario: Standard update on a revisioned form records history + +- **WHEN** `form_update` is called for a form whose stored `revisions` is `'original'`, with `form: { components: [...] }` and `note: "rename email field"` +- **THEN** a single `PUT /form/{id}` is sent with `_vnote: "@formio/mcp: rename email field"` +- **AND** the server-side revision list (`GET /form/{id}/v`) gains a new entry referencing that note + +#### Scenario: Standard update on a revisions-disabled form does not record history + +- **WHEN** `form_update` is called for a form whose stored `revisions` is falsy AND the per-form tracking gate's outcome is "proceed without history" +- **THEN** the PUT is sent without `revisions` on the body +- **AND** no new revision is created (the deployment treats the form as untracked) + +### Requirement: Draft, publish, and revert use field allowlists + +`form_update` with `draft: true` SHALL PUT `/form/{id}/draft` with caller `form` fields restricted to the draft allowlist (`components`, `settings`, `tags`, `properties`, `controller`, `esign`, `display`) and SHALL throw when the body contains any other field. + +`form_update` with `publish: true` SHALL fetch the draft (verifying `_vid === 'draft'`), fetch the live form, and PUT `/form/{id}` with the live form overlaid by the draft's allowlisted fields only. The caller's `form` argument SHALL be ignored. + +`form_update` with `revert: true` SHALL require `version`, fetch the target revision, fetch the live form, and PUT `/form/{id}` with the live form overlaid by the revision's revert allowlist (`components`, `tags`, `properties`, `display`). The caller's `form` argument SHALL be ignored. + +Every draft/publish/revert PUT body SHALL include `_vnote` prefixed with `@formio/mcp:`. + +`draft`, `publish`, `revert` SHALL be mutually exclusive; passing more than one SHALL throw. + +#### Scenario: Draft body with non-allowlisted field is rejected + +- **WHEN** `form_update` is called with `draft: true` and `form: { components: [], title: "X" }` +- **THEN** the tool throws naming the offending field(s) +- **AND** no PUT is sent + +#### Scenario: Publish with no draft errors + +- **WHEN** `form_update` is called with `publish: true` against a form whose `/draft` endpoint returns a non-draft `_vid` +- **THEN** the tool throws "No draft exists" + +#### Scenario: Revert without version errors + +- **WHEN** `form_update` is called with `revert: true` and no `version` +- **THEN** the tool throws requiring `version` + +#### Scenario: Mutually exclusive flags + +- **WHEN** `form_update` is called with both `draft: true` and `publish: true` +- **THEN** the tool throws naming the conflict \ No newline at end of file diff --git a/openspec/changes/mcp-form-revisions/specs/form-update/spec.md b/openspec/changes/mcp-form-revisions/specs/form-update/spec.md new file mode 100644 index 0000000..96f71bc --- /dev/null +++ b/openspec/changes/mcp-form-revisions/specs/form-update/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: form_update requires a note + +`form_update` SHALL require a `note` string parameter describing the diff between the live form and the updated body. The tool SHALL persist it as `_vnote` on the PUT body, prefixed with `@formio/mcp:`, on every write path (standard update, draft, publish, revert). When the stored form has `revisions` enabled, the server creates a new revision on standard updates as well as on publishes — the note is what surfaces in the revision history for either path. For `revert: true`, when `note` is omitted the tool SHALL default it to `Reverted to version {version}`. + +#### Scenario: Note prefixed on standard update + +- **WHEN** `form_update` is called with `note: "rename email field"` +- **THEN** the PUT body's `_vnote` equals `@formio/mcp: rename email field` + +### Requirement: form_update exposes draft, publish, revert flags + +`form_update` SHALL accept `draft`, `publish`, `revert` (booleans) and `version` (string for `revert`). The flags SHALL be mutually exclusive. Behavior of each flag is governed by the `form-revisions` capability. + +#### Scenario: Mutually exclusive + +- **WHEN** `form_update` is called with `draft: true` and `revert: true` +- **THEN** the tool throws + +### Requirement: form_update applies the per-form tracking gate on standard PUTs + +A standard `form_update` (none of `draft`/`publish`/`revert`) SHALL run the per-form revisions tracking gate before issuing the PUT. Gate behavior is specified in the `form-revisions` capability. + +#### Scenario: Tracking gate runs + +- **WHEN** `form_update` is called against a licensed deployment for a form with revisions disabled, no caller opt-in +- **THEN** the tracking gate is invoked before any PUT diff --git a/openspec/changes/mcp-form-revisions/tasks.md b/openspec/changes/mcp-form-revisions/tasks.md new file mode 100644 index 0000000..d45cb8a --- /dev/null +++ b/openspec/changes/mcp-form-revisions/tasks.md @@ -0,0 +1,71 @@ +## 1. Revisions module (license + tracking gates, flows) + + +### Red + +- [x] 1.1 Test: `checkRevisionsLicensed` returns true when `/config.js` contains `sac = true`, false when `sac = false`, false on fetch failure; result cached per `baseUrl`. +- [x] 1.2 Test: license-gate `confirmProceedWithoutRevisions` throws USER CANCELLED on cancel; persists positive consent to `~/.formio/revisions-license-consent.json` with mode 0600; second call for the same `baseUrl` does not re-prompt. +- [x] 1.3 Test: `gateRevisionsLicense` throws when `requiresRevisions: true` and unlicensed; strips `revisions` from `form` when unlicensed and `requiresRevisions: false`; passes through unchanged when licensed. +- [x] 1.4 Test: per-form `gateRevisionsTracking` — no prompt when caller opted in (`revisions: 'original'|'current'`); no prompt when `licensed` is false; prompts when stored `revisions` is falsy; prompts even when caller passes `revisions: ''`; applies caller's choice (`original`/`current`/strip on proceed-without-history); throws on cancel; remembers proceed-without-history for that `formId`. +- [x] 1.5 Test: `saveDraft` rejects bodies with non-allowlisted fields; merges allowlisted fields over existing draft and stamps `_vnote` with `@formio/mcp:` prefix. +- [x] 1.6 Test: `publishDraft` throws when `_vid !== 'draft'`; PUTs live form overlaid with draft's allowlist; ignores caller `form`. +- [x] 1.7 Test: `revertToRevision` PUTs live form overlaid with revision's revert allowlist; ignores caller `form`. + +### Green + +- [x] 1.8 Implement `src/revisions/` (license.ts, tracking.ts, flows.ts, helpers.ts, browser-prompts.ts, index.ts) to pass 1.1–1.7. + +### Refactor + +- [x] 1.9 Review implementation and refactor as needed + +## 2. form_revisions_list + form_revision_get tools + + +### Red + +- [x] 2.1 Test: `form_revisions_list` issues `GET /form/{id}/v` for a Mongo id and `GET /{alias}/v` for a path alias; returns the response as MCP text content. +- [x] 2.2 Test: `form_revision_get` issues `GET /form/{id}/v/{version}` and returns the body as MCP text content. + +### Green + +- [x] 2.3 Implement `form_revisions_list.ts` and `form_revision_get.ts`; register both in `tools/index.ts`. + +### Refactor + +- [x] 2.4 Review implementation and refactor as needed + +## 3. form_update — draft / publish / revert + gates + + +### Red + +- [x] 3.1 Test: `draft`, `publish`, `revert` are mutually exclusive — passing two or more throws. +- [x] 3.2 Test: `revert: true` without `version` throws. +- [x] 3.3 Test: standard PUT runs the per-form tracking gate and applies its returned body, stamping `_vnote` with `@formio/mcp:` prefix so the server records the revision on a revisions-enabled form. +- [x] 3.4 Test: `draft`/`publish`/`revert` delegate to the corresponding flow function with `_vnote` set from `note`. +- [x] 3.5 Test: license gate throws for `draft`/`publish`/`revert` on unlicensed deployments and never PUTs. + +### Green + +- [x] 3.6 Update `form_update.ts` to wire the gates and flow functions; add `draft`, `publish`, `revert`, `version`, `note` to the schema. + +### Refactor + +- [x] 3.7 Review implementation and refactor as needed + +## 4. form_get draft, form_create license/default + + +### Red + +- [x] 4.1 Test: `form_get` with `draft: true` fetches `/{base}/draft` and returns the body when `_vid === 'draft'`; throws "no draft exists" otherwise. +- [x] 4.2 Test: `form_create` on a licensed deployment defaults the POST body to `revisions: 'original'`; caller `revisions` overrides; strips `revisions` and runs license consent on unlicensed deployments; stamps `_vnote` when `note` is provided. + +### Green + +- [x] 4.3 Update `form_get.ts` and `form_create.ts` to match 4.1 and 4.2. + +### Refactor + +- [x] 4.4 Review implementation and refactor as needed diff --git a/packages/mcp-server/src/__tests__/form_create.test.ts b/packages/mcp-server/src/__tests__/form_create.test.ts index 2a4c28f..2fe2942 100644 --- a/packages/mcp-server/src/__tests__/form_create.test.ts +++ b/packages/mcp-server/src/__tests__/form_create.test.ts @@ -6,6 +6,18 @@ vi.mock('../formio-client.js', () => ({ formioFetch: (...args: unknown[]) => mockFormioFetch(...args), })); +// Force the license gate to a no-op pass-through so the revisions consent +// prompt stays silent in tests. +vi.mock('../revisions/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + gateRevisionsLicense: vi + .fn() + .mockImplementation(async (_s, _c, { form }: { form: Record }) => ({ + licensed: true, + form, + })), +})); + const { registerFormCreateTool } = await import('../tools/form_create.js'); describe('form_create tool', () => { @@ -38,7 +50,7 @@ describe('form_create tool', () => { expect(mockFormioFetch).toHaveBeenCalledWith('form', {}, TEST_CONFIG, { method: 'POST', - body: form, + body: { revisions: 'original', ...form }, }); }); @@ -59,7 +71,7 @@ describe('form_create tool', () => { expect(mockFormioFetch).toHaveBeenCalledWith('form', {}, TEST_CONFIG, { method: 'POST', - body: form, + body: { revisions: 'original', ...form }, }); }); @@ -98,4 +110,39 @@ describe('form_create tool', () => { expect.objectContaining({ type: 'text', text: expect.stringContaining('400') }), ]); }); + + it('caller-supplied revisions overrides the default', async () => { + mockFormioFetch.mockResolvedValue({ _id: '123' }); + const { client } = await createTestClient(registerFormCreateTool); + + const form = { + title: 'Pinned', + name: 'pinned', + path: 'pinned', + components: [], + revisions: 'current' as const, + }; + await client.callTool({ name: 'form_create', arguments: { cwd: TEST_CWD, form } }); + + expect(mockFormioFetch).toHaveBeenCalledWith('form', {}, TEST_CONFIG, { + method: 'POST', + body: form, + }); + }); + + it('stamps _vnote with @formio/mcp: prefix when note is provided', async () => { + mockFormioFetch.mockResolvedValue({ _id: '123' }); + const { client } = await createTestClient(registerFormCreateTool); + + const form = { title: 'NF', name: 'nf', path: 'nf', components: [] }; + await client.callTool({ + name: 'form_create', + arguments: { cwd: TEST_CWD, form, note: 'initial' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith('form', {}, TEST_CONFIG, { + method: 'POST', + body: { revisions: 'original', ...form, _vnote: '@formio/mcp: initial' }, + }); + }); }); diff --git a/packages/mcp-server/src/__tests__/form_get.test.ts b/packages/mcp-server/src/__tests__/form_get.test.ts index 66ff6a1..1f3a136 100644 --- a/packages/mcp-server/src/__tests__/form_get.test.ts +++ b/packages/mcp-server/src/__tests__/form_get.test.ts @@ -117,6 +117,37 @@ describe('form_get tool', () => { expect.objectContaining({ type: 'text', text: expect.stringContaining('404') }), ]); }); + + it('with draft: true fetches /{base}/draft and returns the body when _vid === "draft"', async () => { + const id = '67890abcdef012345678abcd'; + const draft = { _vid: 'draft', _id: id, components: [{ type: 'staged' }] }; + mockFormioFetch.mockResolvedValue(draft); + const { client } = await createTestClient(registerFormGetTool); + + const result = await client.callTool({ + name: 'form_get', + arguments: { cwd: TEST_CWD, formIdOrPath: id, draft: true }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${id}/draft`, {}, TEST_CONFIG); + expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(draft, null, 2) }]); + }); + + it('with draft: true throws "no draft exists" when _vid is not "draft"', async () => { + const id = '67890abcdef012345678abcd'; + mockFormioFetch.mockResolvedValue({ _vid: 5, components: [] }); + const { client } = await createTestClient(registerFormGetTool); + + const result = await client.callTool({ + name: 'form_get', + arguments: { cwd: TEST_CWD, formIdOrPath: id, draft: true }, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/No draft exists/) }), + ]); + }); }); describe('isMongoId', () => { 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..228e7f2 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revision_get.test.ts @@ -0,0 +1,34 @@ +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 actual = await importOriginal(); + return { + ...actual, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormRevisionGetTool } = await import('../tools/form_revision_get.js'); + +describe('form_revision_get tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('fetches /form/{id}/v/{version} and returns the body as MCP text', async () => { + const id = '67890abcdef012345678abcd'; + const revision = { _vid: 3, 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: '3' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${id}/v/3`, {}, TEST_CONFIG); + expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(revision, null, 2) }]); + }); +}); 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..4d1c789 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revisions_list.test.ts @@ -0,0 +1,46 @@ +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 actual = await importOriginal(); + return { + ...actual, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormRevisionsListTool } = await import('../tools/form_revisions_list.js'); + +describe('form_revisions_list tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('fetches /form/{id}/v for a Mongo id and returns the body as MCP text', async () => { + const id = '67890abcdef012345678abcd'; + const revisions = [{ _vid: 1, _vnote: 'first' }]; + mockFormioFetch.mockResolvedValue(revisions); + const { client } = await createTestClient(registerFormRevisionsListTool); + + const result = await client.callTool({ + name: 'form_revisions_list', + arguments: { cwd: TEST_CWD, formIdOrPath: id }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${id}/v`, {}, TEST_CONFIG); + expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(revisions, null, 2) }]); + }); + + it('fetches /{alias}/v for a path alias', async () => { + mockFormioFetch.mockResolvedValue([]); + const { client } = await createTestClient(registerFormRevisionsListTool); + + await client.callTool({ + name: 'form_revisions_list', + arguments: { cwd: TEST_CWD, formIdOrPath: 'user/login' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith('user/login/v', {}, TEST_CONFIG); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_update.test.ts b/packages/mcp-server/src/__tests__/form_update.test.ts index 2ce1fa0..128350b 100644 --- a/packages/mcp-server/src/__tests__/form_update.test.ts +++ b/packages/mcp-server/src/__tests__/form_update.test.ts @@ -10,6 +10,19 @@ vi.mock('../formio-client.js', async (importOriginal) => { }; }); +// Force the license gate to a no-op pass-through so it stays silent. Each +// test's mocked stored form sets revisions: 'original' so the real per-form +// tracking gate (preserved via spread) stays silent too. +vi.mock('../revisions/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + gateRevisionsLicense: vi + .fn() + .mockImplementation(async (_s, _c, { form }: { form: Record }) => ({ + licensed: true, + form, + })), +})); + const { registerFormUpdateTool } = await import('../tools/form_update.js'); describe('form_update tool', () => { @@ -27,24 +40,27 @@ describe('form_update tool', () => { expect(tool!.description).toContain('formio-form'); }); - it('sends PUT to /form/{formId} with form body', async () => { + it('sends PUT to /form/{formId} with form body and _vnote prefix', async () => { const formId = '67890abcdef012345678abcd'; - const updated = { _id: formId, title: 'Updated', components: [] }; + const updated = { _id: formId, title: 'Updated', components: [], revisions: 'original' }; mockFormioFetch.mockResolvedValue(updated); const { client } = await createTestClient(registerFormUpdateTool); const form = { title: 'Updated', components: [] }; - await client.callTool({ name: 'form_update', arguments: { cwd: TEST_CWD, formId, form } }); + await client.callTool({ + name: 'form_update', + arguments: { cwd: TEST_CWD, formId, form, note: 'tidy fields' }, + }); expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, TEST_CONFIG, { method: 'PUT', - body: form, + body: { ...form, _vnote: '@formio/mcp: tidy fields' }, }); }); it('passes all form fields in the body', async () => { const formId = '67890abcdef012345678abcd'; - mockFormioFetch.mockResolvedValue({ _id: formId }); + mockFormioFetch.mockResolvedValue({ _id: formId, revisions: 'original' }); const { client } = await createTestClient(registerFormUpdateTool); const form = { @@ -55,23 +71,31 @@ describe('form_update tool', () => { tags: ['updated'], components: [{ type: 'textfield', key: 'name', label: 'Name', input: true }], }; - await client.callTool({ name: 'form_update', arguments: { cwd: TEST_CWD, formId, form } }); + await client.callTool({ + name: 'form_update', + arguments: { cwd: TEST_CWD, formId, form, note: 'rev' }, + }); expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, TEST_CONFIG, { method: 'PUT', - body: form, + body: { ...form, _vnote: '@formio/mcp: rev' }, }); }); it('returns updated form JSON as MCP text content', async () => { const formId = '67890abcdef012345678abcd'; - const updated = { _id: formId, title: 'Updated', components: [] }; + const updated = { _id: formId, title: 'Updated', components: [], revisions: 'original' }; mockFormioFetch.mockResolvedValue(updated); const { client } = await createTestClient(registerFormUpdateTool); const result = await client.callTool({ name: 'form_update', - arguments: { cwd: TEST_CWD, formId, form: { title: 'Updated', components: [] } }, + arguments: { + cwd: TEST_CWD, + formId, + form: { title: 'Updated', components: [] }, + note: 'n', + }, }); expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(updated, null, 2) }]); @@ -83,7 +107,12 @@ describe('form_update tool', () => { const result = await client.callTool({ name: 'form_update', - arguments: { cwd: TEST_CWD, formId: '67890abcdef012345678abcd', form: { components: [] } }, + arguments: { + cwd: TEST_CWD, + formId: '67890abcdef012345678abcd', + form: { components: [] }, + note: 'n', + }, }); expect(result.isError).toBe(true); @@ -91,4 +120,199 @@ describe('form_update tool', () => { expect.objectContaining({ type: 'text', text: expect.stringContaining('404') }), ]); }); + + it('throws when more than one of draft/publish/revert is passed', async () => { + const { client } = await createTestClient(registerFormUpdateTool); + const result = await client.callTool({ + name: 'form_update', + arguments: { + cwd: TEST_CWD, + formId: '67890abcdef012345678abcd', + form: { components: [] }, + note: 'n', + draft: true, + publish: true, + }, + }); + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/mutually exclusive/) }), + ]); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + it('throws when revert is true without version', async () => { + const { client } = await createTestClient(registerFormUpdateTool); + const result = await client.callTool({ + name: 'form_update', + arguments: { + cwd: TEST_CWD, + formId: '67890abcdef012345678abcd', + form: { components: [] }, + note: 'n', + revert: true, + }, + }); + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/requires `version`/) }), + ]); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + it('draft merges caller form over existing draft and stamps _vnote', async () => { + const formId = '67890abcdef012345678abcd'; + const existingDraft = { + _vid: 'draft', + components: [{ type: 'old' }], + display: 'form', + }; + mockFormioFetch.mockResolvedValue(existingDraft); + const { client } = await createTestClient(registerFormUpdateTool); + + await client.callTool({ + name: 'form_update', + arguments: { + cwd: TEST_CWD, + formId, + form: { components: [{ type: 'textfield' }] }, + draft: true, + note: 'staged edits', + }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}/draft`, {}, TEST_CONFIG, { + method: 'PUT', + body: { + _vid: 'draft', + display: 'form', + components: [{ type: 'textfield' }], + _vnote: '@formio/mcp: staged edits', + }, + }); + }); + + it('draft rejects bodies with non-allowlisted fields', async () => { + const { client } = await createTestClient(registerFormUpdateTool); + const result = await client.callTool({ + name: 'form_update', + arguments: { + cwd: TEST_CWD, + formId: '67890abcdef012345678abcd', + form: { components: [], title: 'X' }, + draft: true, + note: 'n', + }, + }); + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/cannot be staged/) }), + ]); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + it('publish throws when no draft exists', async () => { + mockFormioFetch.mockResolvedValueOnce({ _vid: 5, components: [] }); + const { client } = await createTestClient(registerFormUpdateTool); + const result = await client.callTool({ + name: 'form_update', + arguments: { + cwd: TEST_CWD, + formId: '67890abcdef012345678abcd', + form: { components: [] }, + publish: true, + note: 'n', + }, + }); + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/No draft exists/) }), + ]); + }); + + it('publish ignores caller form, overlays draft allowlist on live, stamps _vnote', async () => { + const formId = '67890abcdef012345678abcd'; + const draft = { _vid: 'draft', components: [{ type: 'staged' }], title: 'IGNORED' }; + const live = { + _id: formId, + title: 'Live', + components: [{ type: 'old' }], + access: [{ role: 'admin' }], + }; + mockFormioFetch.mockImplementation( + (path: string, _p: unknown, _c: unknown, opts?: { method?: string }) => { + const isGet = !opts?.method; + if (isGet && path === `form/${formId}/draft`) return Promise.resolve(draft); + if (isGet && path === `form/${formId}`) return Promise.resolve(live); + return Promise.resolve({}); + } + ); + + const { client } = await createTestClient(registerFormUpdateTool); + await client.callTool({ + name: 'form_update', + arguments: { + cwd: TEST_CWD, + formId, + // draft publish flow ignores mcp tool caller form + form: { components: [{ type: 'IGNORED' }] }, + publish: true, + note: 'ship it', + }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, TEST_CONFIG, { + method: 'PUT', + body: { + ...live, + components: [{ type: 'staged' }], + _vnote: '@formio/mcp: ship it', + }, + }); + }); + + it('revert PUTs live overlaid with revision revert allowlist and stamps _vnote', async () => { + const formId = '67890abcdef012345678abcd'; + const revision = { + _vid: '3', + components: [{ type: 'v3' }], + tags: ['t'], + display: 'wizard', + title: 'IGNORED', + }; + const live = { _id: formId, title: 'Live', components: [{ type: 'current' }] }; + mockFormioFetch.mockImplementation( + (path: string, _p: unknown, _c: unknown, opts?: { method?: string }) => { + const isGet = !opts?.method; + if (isGet && path === `form/${formId}/v/3`) return Promise.resolve(revision); + if (isGet && path === `form/${formId}`) return Promise.resolve(live); + return Promise.resolve({}); + } + ); + + const { client } = await createTestClient(registerFormUpdateTool); + await client.callTool({ + name: 'form_update', + arguments: { + cwd: TEST_CWD, + formId, + // revert flow ignores mcp tool caller form + form: { components: [{ type: 'IGNORED' }] }, + revert: true, + version: '3', + note: 'Reverted to version 3', + }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, TEST_CONFIG, { + method: 'PUT', + body: { + ...live, + components: [{ type: 'v3' }], + tags: ['t'], + display: 'wizard', + _vnote: '@formio/mcp: Reverted to version 3', + }, + }); + }); }); diff --git a/packages/mcp-server/src/__tests__/revisions.test.ts b/packages/mcp-server/src/__tests__/revisions.test.ts new file mode 100644 index 0000000..e468363 --- /dev/null +++ b/packages/mcp-server/src/__tests__/revisions.test.ts @@ -0,0 +1,348 @@ +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ResolvedFormioConfig } from '../config.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const mockRequestLicenseConsent = vi.fn(); +const mockRequestRevisionsConsent = vi.fn(); +vi.mock('../revisions/browser-prompts.js', () => ({ + requestRevisionsLicenseConsent: (...args: unknown[]) => mockRequestLicenseConsent(...args), + requestRevisionsConsent: (...args: unknown[]) => mockRequestRevisionsConsent(...args), +})); + +const { checkRevisionsLicensed, confirmProceedWithoutRevisions, gateRevisionsLicense } = + await import('../revisions/license.js'); +const { gateRevisionsTracking } = await import('../revisions/tracking.js'); +const { prefixVnote, stripRevisions } = await import('../revisions/helpers.js'); + +// Minimal stand-in for McpServer with controllable elicitation behavior. +function makeServer(opts: { + elicitation?: boolean; + elicit?: (req: unknown) => Promise; +}): McpServer { + return { + server: { + getClientCapabilities: () => (opts.elicitation ? { elicitation: {} } : {}), + elicitInput: opts.elicit ?? vi.fn(), + }, + } as unknown as McpServer; +} + +const cfgFor = (baseUrl: string): ResolvedFormioConfig => ({ + baseUrl, + projectUrl: `${baseUrl}/proj`, + apiKey: 'k', +}); + +const stubLicensed = (licensed: boolean) => + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(`sac = ${licensed}`), + }) + ); + +const elicitAccept = (choice: string) => + vi.fn().mockResolvedValue({ action: 'accept', content: { choice } }); +const elicitCancel = () => vi.fn().mockResolvedValue({ action: 'cancel' }); + +const uniqueBaseUrl = () => `https://license-${randomUUID()}.local`; +const uniqueFormId = () => `form-${randomUUID()}`; + +const CONSENT_FILE = path.join(os.homedir(), '.formio', 'revisions-license-consent.json'); + +beforeEach(() => { + vi.resetAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('checkRevisionsLicensed', () => { + it('returns true when /config.js contains sac = true', async () => { + stubLicensed(true); + expect(await checkRevisionsLicensed(cfgFor(uniqueBaseUrl()))).toBe(true); + }); + + it('returns false when /config.js reports sac = false', async () => { + stubLicensed(false); + expect(await checkRevisionsLicensed(cfgFor(uniqueBaseUrl()))).toBe(false); + }); + + it('returns false when fetch fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))); + expect(await checkRevisionsLicensed(cfgFor(uniqueBaseUrl()))).toBe(false); + }); + + it('caches per baseUrl — second call does not refetch', async () => { + const baseUrl = uniqueBaseUrl(); + const fetchSpy = vi + .fn() + .mockResolvedValue({ ok: true, text: () => Promise.resolve('sac = true') }); + vi.stubGlobal('fetch', fetchSpy); + await checkRevisionsLicensed(cfgFor(baseUrl)); + await checkRevisionsLicensed(cfgFor(baseUrl)); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('confirmProceedWithoutRevisions', () => { + it('throws USER CANCELLED when user cancels via elicitation', async () => { + const baseUrl = uniqueBaseUrl(); + stubLicensed(false); + const server = makeServer({ elicitation: true, elicit: elicitCancel() }); + + await expect( + confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form') + ).rejects.toThrow(/USER CANCELLED/); + }); + + it('falls back to browser prompt when elicitation unsupported and persists on continue', async () => { + const baseUrl = uniqueBaseUrl(); + stubLicensed(false); + mockRequestLicenseConsent.mockResolvedValue('continue'); + const server = makeServer({ elicitation: false }); + + await confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form'); + + expect(mockRequestLicenseConsent).toHaveBeenCalledWith(baseUrl, 'create this form'); + const data = JSON.parse(await fs.readFile(CONSENT_FILE, 'utf-8')); + expect(data[baseUrl]).toBe(true); + }); + + it('throws USER CANCELLED when browser prompt cancels', async () => { + const baseUrl = uniqueBaseUrl(); + stubLicensed(false); + mockRequestLicenseConsent.mockResolvedValue('cancel'); + const server = makeServer({ elicitation: false }); + + await expect( + confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form') + ).rejects.toThrow(/USER CANCELLED/); + }); + + it('persists positive consent to file and skips re-prompt for same baseUrl', async () => { + const baseUrl = uniqueBaseUrl(); + stubLicensed(false); + const elicit = elicitAccept('continue'); + const server = makeServer({ elicitation: true, elicit }); + + await confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form'); + + const stat = await fs.stat(CONSENT_FILE); + expect(stat.mode & 0o777).toBe(0o600); + const data = JSON.parse(await fs.readFile(CONSENT_FILE, 'utf-8')); + expect(data[baseUrl]).toBe(true); + + // second call → no re-prompt + await confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form'); + expect(elicit).toHaveBeenCalledTimes(1); + }); +}); + +describe('gateRevisionsLicense', () => { + it('throws when requiresRevisions and unlicensed', async () => { + stubLicensed(false); + const server = makeServer({ elicitation: true, elicit: vi.fn() }); + + await expect( + gateRevisionsLicense(server, cfgFor(uniqueBaseUrl()), { + actionLabel: 'save a draft of this form', + requiresRevisions: true, + form: { components: [] }, + }) + ).rejects.toThrow(/Security Module is required/); + }); + + it('strips revisions when requiresRevisions is false and unlicensed', async () => { + stubLicensed(false); + const server = makeServer({ elicitation: true, elicit: elicitAccept('continue') }); + + const result = await gateRevisionsLicense(server, cfgFor(uniqueBaseUrl()), { + actionLabel: 'update this form', + requiresRevisions: false, + form: { revisions: 'original', components: [] }, + }); + expect(result.licensed).toBe(false); + expect(result.form).toEqual({ components: [] }); + }); + + it('passes through unchanged when licensed', async () => { + stubLicensed(true); + const server = makeServer({ elicitation: true, elicit: vi.fn() }); + + const form = { revisions: 'original' as const, components: [] }; + const result = await gateRevisionsLicense(server, cfgFor(uniqueBaseUrl()), { + actionLabel: 'update this form', + requiresRevisions: false, + form, + }); + expect(result.licensed).toBe(true); + expect(result.form).toBe(form); + }); +}); + +describe('gateRevisionsTracking', () => { + const cfg = cfgFor('https://tracking.local'); + + it.each(['original', 'current'] as const)( + 'no prompt when caller opted in via revisions: "%s"', + async (mode) => { + const elicit = vi.fn(); + const server = makeServer({ elicitation: true, elicit }); + + const out = await gateRevisionsTracking(server, { + formId: uniqueFormId(), + form: { revisions: mode, components: [] }, + licensed: true, + cfg, + }); + expect(elicit).not.toHaveBeenCalled(); + expect(mockFormioFetch).not.toHaveBeenCalled(); + expect(out.revisions).toBe(mode); + } + ); + + it('no prompt when licensed is false', async () => { + const elicit = vi.fn(); + const server = makeServer({ elicitation: true, elicit }); + const out = await gateRevisionsTracking(server, { + formId: uniqueFormId(), + form: { components: [] }, + licensed: false, + cfg, + }); + expect(elicit).not.toHaveBeenCalled(); + expect(out).toEqual({ components: [] }); + }); + + it('prompts when stored revisions is falsy and applies enable-original', async () => { + const formId = uniqueFormId(); + mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); + const elicit = elicitAccept('enable-original'); + const server = makeServer({ elicitation: true, elicit }); + + const out = await gateRevisionsTracking(server, { + formId, + form: { components: [] }, + licensed: true, + cfg, + }); + expect(elicit).toHaveBeenCalledTimes(1); + expect(out.revisions).toBe('original'); + }); + + it('prompts even when caller passes revisions: "" and applies enable-current', async () => { + const formId = uniqueFormId(); + mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); + const elicit = elicitAccept('enable-current'); + const server = makeServer({ elicitation: true, elicit }); + + const out = await gateRevisionsTracking(server, { + formId, + form: { revisions: '', components: [] }, + licensed: true, + cfg, + }); + expect(elicit).toHaveBeenCalledTimes(1); + expect(out.revisions).toBe('current'); + }); + + it('proceed-without-history strips revisions and remembers per-form', async () => { + const formId = uniqueFormId(); + mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); + const elicit = elicitAccept('proceed-without-history'); + const server = makeServer({ elicitation: true, elicit }); + + const out = await gateRevisionsTracking(server, { + formId, + form: { revisions: '', components: [] }, + licensed: true, + cfg, + }); + expect(out).toEqual({ components: [] }); + expect('revisions' in out).toBe(false); + + // Second call for same formId — no prompt, no API GET re-fetch needed beyond first. + mockFormioFetch.mockClear(); + elicit.mockClear(); + const out2 = await gateRevisionsTracking(server, { + formId, + form: { components: [{ type: 'textfield', key: 'x' }] }, + licensed: true, + cfg, + }); + expect(elicit).not.toHaveBeenCalled(); + expect(out2).toEqual({ components: [{ type: 'textfield', key: 'x' }] }); + }); + + it('throws when user cancels', async () => { + const formId = uniqueFormId(); + mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); + const server = makeServer({ elicitation: true, elicit: elicitCancel() }); + + await expect( + gateRevisionsTracking(server, { + formId, + form: { components: [] }, + licensed: true, + cfg, + }) + ).rejects.toThrow(/User declined/); + }); + + it('falls back to browser prompt when elicitation unsupported', async () => { + const formId = uniqueFormId(); + mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); + mockRequestRevisionsConsent.mockResolvedValue('enable-original'); + const server = makeServer({ elicitation: false }); + + const out = await gateRevisionsTracking(server, { + formId, + form: { components: [] }, + licensed: true, + cfg, + }); + + expect(mockRequestRevisionsConsent).toHaveBeenCalledWith('demo', formId); + expect(out.revisions).toBe('original'); + }); + + it('does not prompt when stored.revisions is truthy', async () => { + const formId = uniqueFormId(); + mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: 'original' }); + const elicit = vi.fn(); + const server = makeServer({ elicitation: true, elicit }); + + const out = await gateRevisionsTracking(server, { + formId, + form: { components: [] }, + licensed: true, + cfg, + }); + expect(elicit).not.toHaveBeenCalled(); + expect(out).toEqual({ components: [] }); + }); +}); + +describe('helpers', () => { + it('prefixVnote prefixes with @formio/mcp:', () => { + expect(prefixVnote('hello world')).toBe('@formio/mcp: hello world'); + }); + + it('stripRevisions removes the revisions key', () => { + expect(stripRevisions({ revisions: 'current', components: [] })).toEqual({ components: [] }); + expect(stripRevisions({ components: [] })).toEqual({ components: [] }); + }); +}); diff --git a/packages/mcp-server/src/revisions/browser-prompts.ts b/packages/mcp-server/src/revisions/browser-prompts.ts new file mode 100644 index 0000000..9502c34 --- /dev/null +++ b/packages/mcp-server/src/revisions/browser-prompts.ts @@ -0,0 +1,291 @@ +// TEMPORARY: browser-based consent prompt for MCP clients that do not yet support +// the `elicitation` capability. Remove this module (and its call site in +// form_update.ts) once elicitation is universally supported by the clients we +// care about. + +import express from 'express'; +import { exec } from 'child_process'; + +export type RevisionsConsentChoice = + | 'enable-original' + | 'enable-current' + | 'proceed-without-history' + | 'cancel'; + +export type RevisionsLicenseConsentChoice = 'continue' | 'cancel'; + +const esc = (s: string): string => s.replace(/${esc(c.pill)}` : ''; + return ``; +} + +function renderPage(opts: PageOptions): string { + const metaIdHtml = opts.meta.id ? `${esc(opts.meta.id)}` : ''; + const sectionsHtml = opts.sections + .map((s) => { + const title = s.title ? `
${esc(s.title)}
` : ''; + return `${title}
${s.choices.map(renderChoice).join('')}
`; + }) + .join(''); + return ` + + + +${esc(opts.title)} + + + +
+
Form.io
+
+
+

${esc(opts.headerTitle)}

+

${esc(opts.headerSub)}

+
+
+ ${esc(opts.meta.label)}: + ${esc(opts.meta.value)} + ${metaIdHtml} +
+ ${sectionsHtml} +
 
+
+
Form.io MCP local consent prompt · You can close this tab after choosing.
+
+ +`; +} + +export interface BrowserConsentOptions { + onReady?: (port: number) => void; + openBrowser?: boolean; +} + +// Shared runner: spins up a local express server on an ephemeral port, renders +// the consent page, opens the browser, and waits for the browser to POST the +// user's choice back to `/callback`. +async function runBrowserConsent( + page: () => string, + normalize: (raw: string | undefined) => TChoice, + options: BrowserConsentOptions = {} +): Promise { + const app = express(); + app.use(express.json()); + + let resolveChoice!: (value: TChoice) => void; + const choicePromise = new Promise((resolve) => { + resolveChoice = resolve; + }); + + app.get('/', (_req, res) => res.send(page())); + app.post('/callback', (req, res) => { + const raw = (req.body as { choice?: string }).choice; + res.send('Choice captured. You can close this tab.'); + resolveChoice(normalize(raw)); + }); + + const server = app.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (addr && typeof addr !== 'string') { + const consentUrl = `http://127.0.0.1:${addr.port}/`; + if (options.openBrowser !== false) { + const openCmd = + process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'start' + : 'xdg-open'; + exec(`${openCmd} "${consentUrl}"`); + } + options.onReady?.(addr.port); + } + }); + + try { + return await choicePromise; + } finally { + server.close(); + } +} + +export type RequestRevisionsLicenseConsentOptions = BrowserConsentOptions; + +export async function requestRevisionsLicenseConsent( + deploymentLabel: string, + actionLabel: string, + options: RequestRevisionsLicenseConsentOptions = {} +): Promise { + const page = () => + renderPage({ + title: 'Form.io — Security Module Required', + headerTitle: 'Form revisions are not available on this deployment', + headerSub: `The Security Module is required for revision tracking. You can still ${actionLabel}, but history will not be preserved.`, + meta: { label: 'Deployment', value: deploymentLabel }, + sections: [ + { + choices: [ + { + value: 'continue', + title: 'Continue without revision tracking', + desc: 'Proceed with the action. Remembered for this deployment across future sessions.', + variant: 'recommended', + }, + { + value: 'cancel', + title: 'Cancel', + desc: 'Abort the action. No changes are made.', + variant: 'danger', + }, + ], + }, + ], + }); + return runBrowserConsent( + page, + (raw) => (raw === 'continue' ? 'continue' : 'cancel'), + options + ); +} + +export type RequestRevisionsConsentOptions = BrowserConsentOptions; + +const REVISIONS_CHOICES: readonly RevisionsConsentChoice[] = [ + 'enable-original', + 'enable-current', + 'proceed-without-history', + 'cancel', +]; + +export async function requestRevisionsConsent( + formName: string, + formId: string, + options: RequestRevisionsConsentOptions = {} +): Promise { + const page = () => + renderPage({ + title: 'Form.io — Revisions Disabled', + headerTitle: 'Revisions are disabled for this form', + headerSub: + 'Choose how Form.io should track this update. It is recommended to enable revisions. They track every update so you can audit changes, roll back, or pin submissions to a prior form version.', + meta: { label: 'Form', value: formName, id: formId }, + sections: [ + { + title: 'Recommended', + choices: [ + { + value: 'enable-original', + title: 'Enable revisions', + pill: 'Original', + desc: 'Track revision history; submissions render against the form version active when they were submitted.', + variant: 'recommended', + }, + { + value: 'enable-current', + title: 'Enable revisions', + pill: 'Current', + desc: 'Track revision history; submissions always render against the latest form version.', + variant: 'recommended', + }, + ], + }, + { + title: 'Other', + choices: [ + { + value: 'proceed-without-history', + title: 'Update without history', + desc: 'Proceed with the update — no audit trail. The form will continue to operate without revision history.', + }, + { + value: 'cancel', + title: 'Cancel', + desc: 'Make no changes. The pending update is discarded.', + variant: 'danger', + }, + ], + }, + ], + }); + return runBrowserConsent( + page, + (raw) => + REVISIONS_CHOICES.includes(raw as RevisionsConsentChoice) + ? (raw as RevisionsConsentChoice) + : 'cancel', + options + ); +} diff --git a/packages/mcp-server/src/revisions/flows.ts b/packages/mcp-server/src/revisions/flows.ts new file mode 100644 index 0000000..650763b --- /dev/null +++ b/packages/mcp-server/src/revisions/flows.ts @@ -0,0 +1,99 @@ +import { ResolvedFormioConfig } from '../config.js'; +import { formioFetch } from '../formio-client.js'; +import { prefixVnote } from './helpers.js'; + +export const DRAFT_FIELDS = [ + 'components', + 'settings', + 'tags', + 'properties', + 'controller', + 'esign', + 'display', +] as const; + +export const REVERT_FIELDS = ['components', 'tags', 'properties', 'display'] as const; + +export interface DraftFlowOptions { + formId: string; + form: Record; + _vnote: string; + cfg: ResolvedFormioConfig; +} + +export interface RevertOptions { + formId: string; + version: string; + _vnote: string; + cfg: ResolvedFormioConfig; +} + +function pickFields( + source: Record, + fields: readonly string[] +): Record { + return Object.fromEntries(Object.entries(source).filter(([k]) => fields.includes(k))); +} + +// Draft body must be a subset of DRAFT_FIELDS — anything outside that set is +// Reject up front so the LLM picks the right tool +// path (standard form_update for identity/policy edits) instead of staging +// changes that will vanish. +function rejectNonDraftFields(form: Record): void { + const allowed = new Set(DRAFT_FIELDS); + const offending = Object.keys(form).filter((k) => !allowed.has(k)); + if (offending.length === 0) return; + throw new Error( + `Draft body contains fields that cannot be staged: ${offending.join(', ')}. ` + + `Drafts only stage these fields: ${DRAFT_FIELDS.join(', ')}. ` + + `For identity (title, name, path), policy (access, submissionAccess, revisions), ` + + `or other fields, call form_update WITHOUT draft: true to apply them immediately. ` + + `Do NOT retry this call with draft: true.` + ); +} + +export async function saveDraft({ formId, form, _vnote, cfg }: DraftFlowOptions) { + rejectNonDraftFields(form); + // if no draft exists, the endpoint returns the live form + const base = (await formioFetch(`form/${formId}/draft`, {}, cfg)) as Record; + await formioFetch(`form/${formId}/draft`, {}, cfg, { + method: 'PUT', + body: { ...base, ...form, _vnote: prefixVnote(_vnote) }, + }); + // fresh GET since PUT returns stale body + return await formioFetch(`form/${formId}/draft`, {}, cfg); +} + +export async function publishDraft({ formId, _vnote, cfg }: Omit) { + // GET /draft falls back to the live form when no draft exists, so distinguish + // by _vid: only the draft revision has _vid === 'draft'. + const draft = (await formioFetch(`form/${formId}/draft`, {}, cfg)) as Record; + if (draft._vid !== 'draft') { + throw new Error( + `No draft exists for form "${formId}". Create one via form_update with draft: true.` + ); + } + const live = (await formioFetch(`form/${formId}`, {}, cfg)) as Record; + await formioFetch(`form/${formId}`, {}, cfg, { + method: 'PUT', + body: { ...live, ...pickFields(draft, DRAFT_FIELDS), _vnote: prefixVnote(_vnote) }, + }); + return await formioFetch(`form/${formId}`, {}, cfg); +} + +export async function revertToRevision({ formId, version, _vnote, cfg }: RevertOptions) { + const revision = (await formioFetch(`form/${formId}/v/${version}`, {}, cfg)) as Record< + string, + unknown + >; + const live = (await formioFetch(`form/${formId}`, {}, cfg)) as Record; + await formioFetch(`form/${formId}`, {}, cfg, { + method: 'PUT', + body: { + ...live, + ...pickFields(revision, REVERT_FIELDS), + _vnote: prefixVnote(_vnote), + }, + }); + return await formioFetch(`form/${formId}`, {}, cfg); +} diff --git a/packages/mcp-server/src/revisions/helpers.ts b/packages/mcp-server/src/revisions/helpers.ts new file mode 100644 index 0000000..ac7dfd0 --- /dev/null +++ b/packages/mcp-server/src/revisions/helpers.ts @@ -0,0 +1,11 @@ +export const VNOTE_PREFIX = '@formio/mcp:'; + +export function prefixVnote(note: string): string { + return `${VNOTE_PREFIX} ${note}`; +} + +export const stripRevisions = ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + revisions: _revisions, + ...rest +}: Record) => rest; diff --git a/packages/mcp-server/src/revisions/index.ts b/packages/mcp-server/src/revisions/index.ts new file mode 100644 index 0000000..a6f5d73 --- /dev/null +++ b/packages/mcp-server/src/revisions/index.ts @@ -0,0 +1,4 @@ +export { saveDraft, publishDraft, revertToRevision } from './flows.js'; +export { gateRevisionsLicense } from './license.js'; +export { gateRevisionsTracking } from './tracking.js'; +export { prefixVnote } from './helpers.js'; diff --git a/packages/mcp-server/src/revisions/license.ts b/packages/mcp-server/src/revisions/license.ts new file mode 100644 index 0000000..45f2243 --- /dev/null +++ b/packages/mcp-server/src/revisions/license.ts @@ -0,0 +1,178 @@ +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ResolvedFormioConfig } from '../config.js'; +import { + requestRevisionsLicenseConsent, + RevisionsLicenseConsentChoice, +} from './browser-prompts.js'; +import { stripRevisions } from './helpers.js'; + +// ─── License detection ────────────────────────────────────────────────────── +// Resolves the deployment's Security Module flag (`sac`) from the anonymous +// `/config.js`. Cached per `baseUrl` — license is deployment-wide. +const revisionsLicensedByBaseUrl = new Map(); + +const SAC_PATTERN = /\bsac\s*=\s*(true|false)\b/i; + +export async function checkRevisionsLicensed(cfg: ResolvedFormioConfig): Promise { + const cached = revisionsLicensedByBaseUrl.get(cfg.baseUrl); + if (cached !== undefined) return cached; + + let revisionsLicensed = false; + try { + const url = new URL('config.js', `${cfg.baseUrl.replace(/\/*$/, '/')}`); + const response = await fetch(url); + if (response.ok) { + const body = await response.text(); + const match = body.match(SAC_PATTERN); + revisionsLicensed = match?.[1]?.toLowerCase() === 'true'; + } + } catch { + revisionsLicensed = false; + } + + revisionsLicensedByBaseUrl.set(cfg.baseUrl, revisionsLicensed); + return revisionsLicensed; +} + +// ─── Persistent consent store ─────────────────────────────────────────────── +// `~/.formio/revisions-license-consent.json`, keyed by `baseUrl`. Only +// positive consent ("continue") is persisted; cancel is transient. +const DEFAULT_CACHE_DIR = path.join(os.homedir(), '.formio'); +const CACHE_FILE = 'revisions-license-consent.json'; + +async function readCache(cacheDir: string): Promise> { + const filePath = path.join(cacheDir, CACHE_FILE); + try { + const contents = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(contents) as Record; + const result: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + if (v === true) result[k] = true; + } + return result; + } catch { + return {}; + } +} + +async function writeCache(cacheDir: string, data: Record): Promise { + await fs.mkdir(cacheDir, { recursive: true }); + const filePath = path.join(cacheDir, CACHE_FILE); + await fs.writeFile(filePath, JSON.stringify(data), { mode: 0o600 }); +} + +async function readRevisionsLicenseConsent( + baseUrl: string, + cacheDir: string = DEFAULT_CACHE_DIR +): Promise { + const data = await readCache(cacheDir); + return data[baseUrl] === true; +} + +async function saveRevisionsLicenseConsent( + baseUrl: string, + cacheDir: string = DEFAULT_CACHE_DIR +): Promise { + const data = await readCache(cacheDir); + data[baseUrl] = true; + await writeCache(cacheDir, data); +} + +// ─── In-memory consent layered over the persistent store ─────────────────── +// Lookup order: memory → disk → prompt. +const revisionsLicenseConsentByBaseUrl = new Map(); + +export async function getRevisionsLicenseConsent( + server: McpServer, + cfg: ResolvedFormioConfig, + actionLabel: string +): Promise { + const cached = revisionsLicenseConsentByBaseUrl.get(cfg.baseUrl); + if (cached) return cached; + + if (await readRevisionsLicenseConsent(cfg.baseUrl)) { + revisionsLicenseConsentByBaseUrl.set(cfg.baseUrl, 'continue'); + return 'continue'; + } + + const supportsElicitation = Boolean(server.server.getClientCapabilities()?.elicitation); + let choice: RevisionsLicenseConsentChoice; + if (supportsElicitation) { + const result = await server.server.elicitInput({ + message: `Form revisions are not available on this Form.io deployment (the Security Module is required on the license). You can still ${actionLabel}, but history will not be saved. Continue?`, + requestedSchema: { + type: 'object', + properties: { + choice: { + type: 'string', + title: 'How to proceed', + enum: ['continue', 'cancel'], + enumNames: [ + 'Continue without revision tracking (remembered for this deployment across future sessions)', + 'Cancel', + ], + }, + }, + required: ['choice'], + }, + }); + if (result.action !== 'accept' || !result.content?.choice) { + choice = 'cancel'; + } else { + choice = result.content.choice === 'continue' ? 'continue' : 'cancel'; + } + } else { + // TEMPORARY: browser-consent fallback for MCP clients that do not yet support elicitation. + choice = await requestRevisionsLicenseConsent(cfg.baseUrl, actionLabel); + } + + // Only cache positive consent — cancel is transient so users can change their mind. + if (choice === 'continue') { + revisionsLicenseConsentByBaseUrl.set(cfg.baseUrl, choice); + await saveRevisionsLicenseConsent(cfg.baseUrl); + } + return choice; +} + +// Prompts the user if they want to proceed when revisions are not enabled on the license +export async function confirmProceedWithoutRevisions( + server: McpServer, + cfg: ResolvedFormioConfig, + actionLabel: string +): Promise { + const consent = await getRevisionsLicenseConsent(server, cfg, actionLabel); + if (consent === 'cancel') { + throw new Error( + `USER CANCELLED. The user explicitly chose to cancel: ${actionLabel}. Do NOT retry. Do NOT suggest workarounds, alternative projects, or enabling the Security Module. Do NOT offer to switch deployments. Simply acknowledge the cancellation to the user in one short sentence and stop. The user is aware of why they cancelled.` + ); + } +} + +// Top-level license gate. Throws when the action requires revisions on an +// unlicensed deployment; otherwise prompts for "continue without history" +// consent when unlicensed. Returns the resolved licensed flag and the form +// body with `revisions` stripped when unlicensed (passthrough when licensed). +export async function gateRevisionsLicense( + server: McpServer, + cfg: ResolvedFormioConfig, + { + actionLabel, + requiresRevisions, + form, + }: { actionLabel: string; requiresRevisions: boolean; form: Record } +): Promise<{ licensed: boolean; form: Record }> { + const licensed = await checkRevisionsLicensed(cfg); + if (!licensed && requiresRevisions) { + throw new Error( + `Cannot ${actionLabel} — the Security Module is required to use revisions, so drafts, publishes, and reverts are unavailable. Drop the draft/publish/revert flag and call form_update as a standard update to apply your changes.` + ); + } + // for standard creates/updates, confirm with the user that they don't care if history is not preserved + if (!licensed) { + await confirmProceedWithoutRevisions(server, cfg, actionLabel); + } + return { licensed, form: licensed ? form : stripRevisions(form) }; +} diff --git a/packages/mcp-server/src/revisions/tracking.ts b/packages/mcp-server/src/revisions/tracking.ts new file mode 100644 index 0000000..591299d --- /dev/null +++ b/packages/mcp-server/src/revisions/tracking.ts @@ -0,0 +1,127 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ResolvedFormioConfig } from '../config.js'; +import { formioFetch } from '../formio-client.js'; +import { requestRevisionsConsent, RevisionsConsentChoice } from './browser-prompts.js'; +import { stripRevisions } from './helpers.js'; + +// Session-scoped set of formIds the user already approved "without history" for. +// Module-level so per-form approval persists across calls +const approvedWithoutHistory = new Set(); + +// Per-form revisions-tracking mode gate. Distinct from the deployment-level +// license gate in license.ts: this prompt asks "for THIS specific form, how +// should revisions be tracked" (original / current / off), not "is the +// deployment licensed at all." +// +// The prompt fires ONLY when ALL of the following hold: +// 1. Deployment IS licensed for revisions (`licensed` is true). +// 2. The targeted form has revisions disabled (`stored.revisions` is falsy). +// 3. The caller did not opt in by passing `revisions: 'original' | 'current'` +// on the body. +// 4. The user has not already approved "proceed without history" for this +// form in the current session. +// +// If any of those fail, the gate is a no-op and returns the body unchanged. + +async function promptRevisionsMode( + server: McpServer, + stored: Record, + formId: string +): Promise { + const supportsElicitation = Boolean(server.server.getClientCapabilities()?.elicitation); + if (supportsElicitation) { + const result = await server.server.elicitInput({ + message: `Form "${stored.name ?? formId}" has revisions disabled. It is recommended to enable revisions. They track every update so you can audit changes, roll back, or pin submissions to a prior form version. How would you like to proceed?`, + requestedSchema: { + type: 'object', + properties: { + choice: { + type: 'string', + title: 'Revision mode for this update', + enum: ['enable-original', 'enable-current', 'proceed-without-history'], + enumNames: [ + 'Enable revisions (original) and update', + 'Enable revisions (current) and update', + 'Proceed without history (not tracked)', + ], + description: + 'original = submissions render against the form version active when they were submitted; current = submissions always render against the latest form version; proceed-without-history = no audit trail.', + }, + }, + required: ['choice'], + }, + }); + if (result.action !== 'accept' || !result.content?.choice) return 'cancel'; + const c = result.content.choice; + if (c === 'enable-original' || c === 'enable-current' || c === 'proceed-without-history') { + return c; + } + return 'cancel'; + } + // TEMPORARY: browser-consent fallback for MCP clients that do not yet support elicitation. + // Remove once elicitation is universally supported by the clients we care about. + const formName = typeof stored.name === 'string' ? stored.name : formId; + return requestRevisionsConsent(formName, formId); +} + +export interface RevisionsTrackingGateOptions { + formId: string; + form: Record; + /** Whether the deployment is licensed for revisions. When false, skip the per-form prompt. */ + licensed: boolean; + cfg: ResolvedFormioConfig; +} + +// Returns the stored form when a per-form revisions prompt is required; +// returns null when the gate should be bypassed. +// +// Bypass rule: only treat the caller as having supplied `revisions` when they +// opted IN to tracking (`original` / `current`). Passing `revisions: ''` +// mirrors the disabled stored state and must NOT bypass the prompt — that +// loophole lets an LLM silently skip the audit-trail decision on every form +// by always echoing the disabled value. +async function shouldPromptForRevisions( + opts: RevisionsTrackingGateOptions +): Promise | null> { + const { formId, form, licensed, cfg } = opts; + const callerOptedIn = form.revisions === 'original' || form.revisions === 'current'; + // Skip when the deployment doesn't have revisions enabled on the license — the user's "continue without + // revision tracking" choice is captured at the license-gate layer, so + // re-prompting per-form is redundant. + if (callerOptedIn || !licensed) return null; + const stored = (await formioFetch(`form/${formId}`, {}, cfg)) as Record; + if (stored.revisions || approvedWithoutHistory.has(formId)) return null; + return stored; +} + +// Returns the PUT body for the standard form_update path with the user's +// per-form revisions-mode choice applied. Throws on cancel so the calling +// tool's outer try/catch surfaces it via toMcpError. +export async function gateRevisionsTracking( + server: McpServer, + opts: RevisionsTrackingGateOptions +): Promise> { + const { formId, form } = opts; + let putBody: Record = { ...form }; + + const stored = await shouldPromptForRevisions(opts); + if (!stored) return putBody; + + const choice = await promptRevisionsMode(server, stored, formId); + if (choice === 'cancel') { + throw new Error(`User declined to update form "${formId}". No changes were made.`); + } + if (choice === 'enable-original' || choice === 'enable-current') { + putBody = { + ...putBody, + revisions: choice === 'enable-original' ? 'original' : 'current', + }; + return putBody; + } + + // proceed-without-history: drop any caller-supplied `revisions: ''` so the + // PUT body matches "no revisions change". Remember for the rest of the + // session so the user is asked only once per form. + approvedWithoutHistory.add(formId); + return stripRevisions(putBody); +} diff --git a/packages/mcp-server/src/tools/form_create.ts b/packages/mcp-server/src/tools/form_create.ts index c88a560..db40a39 100644 --- a/packages/mcp-server/src/tools/form_create.ts +++ b/packages/mcp-server/src/tools/form_create.ts @@ -4,11 +4,12 @@ import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpTextResult, toMcpError } from '../mcp-responses.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { gateRevisionsLicense, prefixVnote } from '../revisions/index.js'; export function registerFormCreateTool(server: McpServer, config: FormioConfig) { server.tool( 'form_create', - "Create a new form in the Form.io project mapped to the user's current working directory. IMPORTANT: Before calling this tool, use the formio-form skill to construct a properly structured Form.io form JSON definition based on the user's requirements. The skill documents all component types, validation options, layout patterns, and conditional logic available in Form.io.", + 'Create a new form in the Form.io project mapped to the user\'s current working directory. IMPORTANT: Before calling this tool, use the formio-form skill to construct a properly structured Form.io form JSON definition based on the user\'s requirements. The skill documents all component types, validation options, layout patterns, and conditional logic available in Form.io. New forms default to `revisions: \'original\'` so form change history is preserved. NOT for: creating a draft revision of an existing form. When the user says "create/save a draft", "draft ", call form_update with `formId` and `draft: true` instead.', { cwd: cwdSchema, form: z @@ -25,16 +26,31 @@ export function registerFormCreateTool(server: McpServer, config: FormioConfig) .optional() .describe('Display mode (default: "form")'), tags: z.array(z.string()).optional().describe('Tags for categorization'), + revisions: z + .enum(['current', 'original', '']) + .optional() + .describe('Revision mode (default: "original"). Pass "" to disable'), }) .catchall(z.unknown()) .describe('Form.io form JSON definition'), + note: z.string().optional().describe('Note describing the initial revision'), }, - async ({ cwd, form }) => { + async ({ cwd, form: rawForm, note }) => { try { const cfg = resolveProjectConfig(cwd, config); + const { licensed, form } = await gateRevisionsLicense(server, cfg, { + actionLabel: 'create this form', + requiresRevisions: false, + form: rawForm, + }); const created = await formioFetch('form', {}, cfg, { method: 'POST', - body: form, + body: { + // gate already stripped `revisions` on unlicensed deployments; on + // licensed ones default to 'original' unless the caller overrode. + ...(licensed ? { revisions: 'original', ...form } : form), + ...(note && { _vnote: prefixVnote(note) }), + }, }); return toMcpTextResult(created); } catch (error) { diff --git a/packages/mcp-server/src/tools/form_get.ts b/packages/mcp-server/src/tools/form_get.ts index 390b954..2f5bd78 100644 --- a/packages/mcp-server/src/tools/form_get.ts +++ b/packages/mcp-server/src/tools/form_get.ts @@ -8,7 +8,7 @@ import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; export function registerFormGetTool(server: McpServer, config: FormioConfig) { server.tool( 'form_get', - "Fetch a single form definition from the Form.io project mapped to the user's current working directory, by form ID or path.", + "Fetch a single form definition from the Form.io project mapped to the user's current working directory, by form ID or path. Pass `draft: true` to fetch the form's current in-flight draft instead of the published form.", { cwd: cwdSchema, formIdOrPath: z.string().describe('Form ID (_id) or path (e.g. "user/login")'), @@ -16,13 +16,25 @@ export function registerFormGetTool(server: McpServer, config: FormioConfig) { .string() .optional() .describe('Comma-separated fields to return (omit for full form JSON)'), + draft: z + .boolean() + .optional() + .describe("When true, fetch the form's current draft (GET /
/draft)"), }, - async ({ cwd, formIdOrPath, select }) => { + async ({ cwd, formIdOrPath, select, draft }) => { try { const cfg = resolveProjectConfig(cwd, config); const params: Record = { select }; - const path = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; - const form = await formioFetch(path, params, cfg); + const base = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; + const path = draft ? `${base}/draft` : base; + const form = (await formioFetch(path, params, cfg)) as Record; + // GET /draft falls back to the live form when no draft exists, so + // distinguish by _vid: only the draft revision has _vid === 'draft'. + if (draft && form._vid !== 'draft') { + throw new Error( + `No draft exists for form "${formIdOrPath}". Create one via form_update with draft: true.` + ); + } return toMcpTextResult(form); } 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..e4080f8 --- /dev/null +++ b/packages/mcp-server/src/tools/form_revision_get.ts @@ -0,0 +1,28 @@ +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 single immutable form revision from the Form.io project mapped to the current working directory. `version` accepts either the revision `_vid` (e.g. "3") or the revision document `_id` (24-character hex). To revert the live form to this revision, pass its `form` body to `form_update` with a `note` like `Revert to v`.', + { + cwd: cwdSchema, + formIdOrPath: z.string().describe('Form ID (_id) or path alias (e.g. "user/login")'), + version: z.string().describe('Revision _vid (e.g. "3") or revision document _id'), + }, + async ({ cwd, formIdOrPath, version }) => { + try { + const cfg = resolveProjectConfig(cwd, config); + const base = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; + const revision = await formioFetch(`${base}/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..ae46ca9 --- /dev/null +++ b/packages/mcp-server/src/tools/form_revisions_list.ts @@ -0,0 +1,27 @@ +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 registerFormRevisionsListTool(server: McpServer, config: FormioConfig) { + server.tool( + 'form_revisions_list', + 'List immutable published revision summaries for a single form in the Form.io project mapped to the current working directory. Returns compact revision metadata (_vid, _id, modified, user, _vnote) for the form identified by `formIdOrPath`. To inspect a specific revision body, call `form_revision_get` with the desired `_vid`. To revert the live form to a prior revision, pass that revision body to `form_update` with a `note` like `Revert to v`.', + { + cwd: cwdSchema, + formIdOrPath: z.string().describe('Form ID (_id) or path alias (e.g. "user/login")'), + }, + async ({ cwd, formIdOrPath }) => { + try { + const cfg = resolveProjectConfig(cwd, config); + const base = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; + const revisions = await formioFetch(`${base}/v`, {}, cfg); + return toMcpTextResult(revisions); + } 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..490d548 100644 --- a/packages/mcp-server/src/tools/form_update.ts +++ b/packages/mcp-server/src/tools/form_update.ts @@ -4,11 +4,23 @@ import { FormioConfig } from '../config.js'; import { formioFetch, MONGO_ID_PATTERN } from '../formio-client.js'; import { toMcpTextResult, toMcpError } from '../mcp-responses.js'; import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { + gateRevisionsLicense, + gateRevisionsTracking, + prefixVnote, + publishDraft, + revertToRevision, + saveDraft, +} from '../revisions/index.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.", + '`draft`, `publish`, and `revert` are mutually exclusive — pass at most one.', + 'If `revisions` in the response differs from the stored value, the per-form revisions-mode gate prompted the USER and they chose.', + ].join(' '), { cwd: cwdSchema, formId: z @@ -26,18 +38,92 @@ export function registerFormUpdateTool(server: McpServer, config: FormioConfig) type: z.enum(['form', 'resource']).optional().describe('Form type'), display: z.enum(['form', 'wizard', 'pdf']).optional().describe('Display mode'), tags: z.array(z.string()).optional().describe('Tags for categorization'), + revisions: z + .enum(['current', 'original', '']) + .optional() + .describe( + 'Revision mode. Pass "" to disable; omit to leave the stored value unchanged.' + ), }) .catchall(z.unknown()) .describe('Complete updated Form.io form JSON definition'), + note: z + .string() + .describe( + 'Required note describing the diff (live form vs updated body) — no action preambles ("Published draft:", "Saved draft:", "Reverted:"). For `revert: true`, default to "Reverted to version {version}" unless the user explicitly provides a different note.' + ), + draft: z + .boolean() + .optional() + .describe( + 'When true, create or update a draft (PUT /form/{formId}/draft) instead of publishing. Caller `form` fields merge on top of existing draft fields, preserving prior unpublished draft edits.' + ), + publish: z + .boolean() + .optional() + .describe( + 'When true, publish the current draft. Caller `form` body is ignored; only allowlisted revision fields flow from existing draft to live (PUT /form/{formId}).' + ), + revert: z + .boolean() + .optional() + .describe( + 'When true, revert the live form to a prior revision. Requires `version`. Caller `form` body is ignored; only allowlisted revision fields flow from the revision to live.' + ), + version: z + .string() + .optional() + .describe( + 'Revision identifier for `revert: true` — either the revision `_vid` (e.g. "3") or the revision document `_id` (24-char hex).' + ), }, - async ({ cwd, formId, form }) => { + async ({ cwd, formId, form: rawForm, note, draft, publish, revert, version }) => { try { + const exclusiveFlags = [draft, publish, revert].filter(Boolean); + if (exclusiveFlags.length > 1) { + throw new Error( + '`draft`, `publish`, and `revert` flags are mutually exclusive — pass only one.' + ); + } + if (revert && !version) { + throw new Error('`revert: true` requires `version` (revision `_vid` or document `_id`).'); + } const cfg = resolveProjectConfig(cwd, config); - const updated = await formioFetch(`form/${formId}`, {}, cfg, { - method: 'PUT', - body: form, + + const actionLabel = `${revert ? 'revert' : publish ? 'publish' : draft ? 'save a draft of' : 'update'} this form`; + const { licensed, form } = await gateRevisionsLicense(server, cfg, { + actionLabel, + requiresRevisions: Boolean(draft || publish || revert), + form: rawForm, }); - return toMcpTextResult(updated); + + if (revert || publish || draft) { + const args = { formId, _vnote: note, cfg }; + return toMcpTextResult( + await (revert && version + ? revertToRevision({ ...args, version }) + : publish + ? publishDraft(args) + : saveDraft({ ...args, form })) + ); + } + + // Standard PUT path. Apply per-form revisions consent (prompts when + // the stored form has revisions disabled and the caller did not opt + // in via `revisions: 'original'|'current'`). + const putBody = await gateRevisionsTracking(server, { + formId, + form, + licensed, + cfg, + }); + + return toMcpTextResult( + await formioFetch(`form/${formId}`, {}, cfg, { + method: 'PUT', + body: { ...putBody, _vnote: prefixVnote(note) }, + }) + ); } catch (error) { return toMcpError(error); } diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index d2c200b..fd42611 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -10,6 +10,8 @@ import { registerActionUpdateTool } from './action_update.js'; import { registerFormCreateTool } from './form_create.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 { registerFormUpdateTool } from './form_update.js'; import { registerHelloTool } from './hello.js'; import { registerProjectExportTool } from './project_export.js'; @@ -32,6 +34,8 @@ export function registerAllTools( registerFormCreateTool(server, config); registerFormGetTool(server, config); registerFormListTool(server, config); + registerFormRevisionGetTool(server, config); + registerFormRevisionsListTool(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..c9a3e08 100644 --- a/plugin/skills/formio-api/references/project-form-revisions.md +++ b/plugin/skills/formio-api/references/project-form-revisions.md @@ -11,9 +11,48 @@ All endpoints below are rooted at `${FORMIO_PROJECT_URL}` — the project endpoi Every request to these endpoints MUST include an `x-jwt-token` header holding the user JWT issued by the MCP server's browser-based portal-login flow. The MCP server attaches this header automatically via `formioFetch`; external clients must obtain the JWT through the same portal-login flow. Do not use any other authentication mechanism with these endpoints. +## Key Behaviors + +- **Every form created via `form_create` defaults to `revisions: 'original'`** on a licensed deployment. The caller may override by passing `revisions: 'current'` or `revisions: ''` on the form body, but the tool's default is `original` so submission history is preserved out of the box. On unlicensed deployments, `revisions` is stripped from the body entirely. +- **Every `form_update` call writes a revision note (`note`).** The caller passes one for standard updates, drafts, publishes, and explicit revert notes; for `revert: true` the tool defaults `note` to `Reverted to version {version}` when the caller omits it. The note is prefixed (`@formio/mcp:`) and persisted on the revision document — never skipped. + ## MCP Tool Preference -No MCP tool covers this operation — use the HTTP endpoint directly. +Prefer the MCP server's first-party tools for every operation on this page; fall back to the raw HTTP endpoints only if the tool cannot satisfy the request. + +- **List revisions** (`GET /form/:id/v`) — use `form_revisions_list`. Accepts the form by `_id` or path alias. +- **Get a single revision** (`GET /form/:id/v/:version`) — use `form_revision_get`. `version` may be the sequential `_vid` or the revision document's `_id`. +- **Inspect the current draft** (`GET /form/:id/draft`) — use `form_get` with `draft: true`. Accepts the form by `_id` or path alias. The underlying endpoint falls back to the live form when no draft exists; the tool distinguishes by checking `_vid === 'draft'` and throws a clear "no draft exists" error when the fallback fires. +- **Enable or change revisions setting** (`PUT /form/:id` with `revisions`) — use `form_update` and pass `revisions: "current" | "original" | ""` on the form body. Omitting `revisions` on `form_update` leaves the stored value unchanged; when the stored form has revisions disabled and the caller did NOT opt in via `revisions: 'original' | 'current'`, the tool prompts (elicitation, with a browser fallback) for the per-form mode before applying the update. +- **Save a draft** (`PUT /form/:id/draft`) — use `form_update` with `draft: true`. Caller `form` fields merge on top of the existing draft (caller wins), preserving prior unpublished edits. +- **Publish the current draft** (`PUT /form/:id` from `/draft` body) — use `form_update` with `publish: true`. The tool fetches the staged draft and the live form, then PUTs the live form overlaid with a strict revision-field allowlist from the draft: `components`, `settings`, `tags`, `properties`, `controller`, `esign`, `display`. All other fields (`title`, `name`, `path`, `type`, `access`, `submissionAccess`, `submissionRevisions`, `owner`, `project`, `revisions`, identity/server-managed fields) keep their live values. The caller's `form` argument is ignored in this mode; `note` must still describe the actual diff between the live form and the draft (generic placeholders like "publishing changes" are forbidden). +- **Revert to a prior revision** — use `form_update` with `revert: true` and `version: ""` (a sequential `_vid` like `"3"`) or `version: ""` (the 24-char hex revision document `_id`). The tool fetches that revision and the live form, then PUTs live overlaid with a narrower revert allowlist: `components`, `tags`, `properties`, `display`. All other fields keep their live values; the caller's `form` argument is ignored. Inspect the target revision via `form_revision_get` first so `note` can describe what reverting restores (e.g. `Revert to v3: rollback bad release`); when omitted, the tool defaults `note` to `Reverted to version {version}`. `draft`, `publish`, and `revert` are mutually exclusive — pass at most one (the tool throws when more than one is set). + +`note` is required on every `form_update` call EXCEPT `revert: true` (which defaults the note to `Reverted to version {version}` when the caller omits it). The LLM SHALL generate it by diffing the prior state against the new body — no action preambles (`Published draft:`, `Saved draft:`, `Reverted:`). + +### License gating + +`draft`, `publish`, and `revert` require the Security Module on the deployment's license. When the deployment is unlicensed: + +- `draft` / `publish` / `revert` — the tool throws immediately telling the caller to drop the flag and call `form_update` as a standard update. +- Standard updates — the tool prompts once per deployment for "continue without revision tracking" consent (cached across sessions in `~/.formio/revisions-license-consent.json`). On consent, the `revisions` field is stripped from the body so the API doesn't silently write a value it can't honor. + +### Per-form revisions-mode gate + +Distinct from the deployment-level license gate above, this gate asks "for THIS specific form, how should revisions be tracked." It fires on a standard `form_update` ONLY when ALL of the following hold: + +1. The deployment IS licensed for revisions. +2. The stored form has `revisions` disabled (falsy). +3. The caller did NOT opt in by passing `revisions: 'original' | 'current'` on the body. Passing `revisions: ''` mirrors the disabled stored state and does NOT bypass the prompt — that loophole would let an LLM silently skip the audit-trail decision on every form by always echoing the disabled value. +4. The user has not already approved "proceed without history" for this form in the current process (session-scoped cache). + +When all conditions hold, the tool prompts (elicitation, with a browser fallback) with three choices: + +- **Enable revisions (original)** — submissions render against the form version active when they were submitted. Tool sets `revisions: 'original'` on the PUT body. +- **Enable revisions (current)** — submissions always render against the latest form version. Tool sets `revisions: 'current'` on the PUT body. +- **Proceed without history (not tracked)** — tool strips any caller-supplied `revisions` from the PUT body and remembers the approval for this `formId` for the rest of the process so the user is asked only once per form. + +On cancel, the tool throws and no update is performed. ## Endpoints @@ -87,9 +126,7 @@ Retrieve the current draft for a revisioned form. | --- | --- | --- | | `formId` | string | The MongoDB `_id` of the form. | -Response: the draft form document, including `_id`, `title`, `name`, `path`, `components`, `access`, `submissionAccess`, and any in-progress edits. - -Errors: `404` if no draft exists or the form has no revisions enabled; `401`/`403` as above. +Response: the draft form document, including `_id`, `title`, `name`, `path`, `components`, `access`, `submissionAccess`, and any in-progress edits. Example: