Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/mcp-form-revisions/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-tdd
created: 2026-05-21
57 changes: 57 additions & 0 deletions openspec/changes/mcp-form-revisions/design.md
Original file line number Diff line number Diff line change
@@ -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<formId>` 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.
34 changes: 34 additions & 0 deletions openspec/changes/mcp-form-revisions/proposal.md
Original file line number Diff line number Diff line change
@@ -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`.
29 changes: 29 additions & 0 deletions openspec/changes/mcp-form-revisions/specs/form-create/spec.md
Original file line number Diff line number Diff line change
@@ -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`
15 changes: 15 additions & 0 deletions openspec/changes/mcp-form-revisions/specs/form-get/spec.md
Original file line number Diff line number Diff line change
@@ -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`
122 changes: 122 additions & 0 deletions openspec/changes/mcp-form-revisions/specs/form-revisions/spec.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading