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
17 changes: 17 additions & 0 deletions .changeset/freeze-1-0-tool-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@formio/mcp': major
'@formio/ai': major
---

The 1.0 tool surface. Breaking changes for any client calling the tools directly; the skills and READMEs ship updated in the same release.

- **Renamed tools:** `action_types_list` → `action_type_list`, `form_revisions_list` → `form_revision_list`, `hello` → `server_status` (server name and version plus the `cwd`'s project resolution; no Form.io request). The old names are not registered.
- **Publish and revert are their own tools:** `form_publish` (`formId`, `note`) and `form_revert` (`formId`, `version`, `note`). `form_update` no longer takes `publish`, `revert` or `version`; its `draft: true` mode saves the draft fields of the body it is given and ignores the rest, so `form_get`'s output can be passed back.
- **Form addressing:** `formId` is always a 24-character ObjectId (every action tool, `form_update`, `form_publish`, `form_revert`); `formIdOrPath` accepts either and is taken by `form_get`, `form_revision_list` and `form_revision_get`.
- **`role_create`** takes a nested `role` object, like `role_update`.
- **One list contract:** `form_list`, `role_list`, `action_list` and `form_revision_list` take `limit` (default 100), `skip`, `sort` and `select`, and return `total` and `hasMore` read from Form.io's `Content-Range`; `count` is removed. Lists no longer stop at Form.io's default of 10. `form_list`'s `tags` matches forms carrying all of them; `form_revision_list` is newest first with compact fields; `action_type_list` returns the whole catalog.
- **No consent prompts:** a write that would save without revision history (an unlicensed deployment, a form with revisions off, or an explicit `revisions: ""`) needs `acceptNoHistory: true`, otherwise it is refused with `HISTORY_NOT_ACCEPTED` for the agent to ask the user. The elicitation and local browser consent pages, and `~/.formio/revisions-license-consent.json`, are removed.
- **Errors:** every tool error's text starts with a `[CODE]` from a fixed set, and the result carries `{ code, status?, body? }` in `_meta["io.form/error"]`, including Form.io's response body. Existing message text is unchanged after the prefix.
- **Output schemas are open:** a valid Form.io response with fields a schema does not list (`_vid`, `pdfComponents`, `controller`, `esign`, `null` settings) is no longer rejected by clients that validate output.
- **Smaller surface text:** `tools/list` is about 48.6k characters (from 68.5k), under a tested 50,000 budget; the full project-resolution rules stay in the server instructions.
- `FORMIO_INSECURE_TLS` and `FORMIO_FORCE_BROWSER` both accept `true` or `1`, case-insensitive. `@formio/mcp` exports only `./package.json`: the package is its `formio-mcp` binary. The `project get` / `project set` flags and exit codes (0, 1, 2, 3) are documented as the 1.0 CLI reference, unchanged.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Project configuration: there is ONE value to supply — the **Project URL**, the

A record holds a project and its deployment as a PAIR, and resolution picks ONE record by scope, narrowest first: a committed `formio.json` found by walking up from the working directory (stopping at a directory containing `.git`), then the working-directory mapping in `~/.formio/projects.json`, then `FORMIO_PROJECT_URL` / `FORMIO_BASE_URL` in the environment, which are the WEAKEST source. Both resolved values come from the winning record — its own deployment, or the one derived from its project URL — and halves are never combined across records, so a `FORMIO_BASE_URL` in the environment does not supply the deployment for a project a mapping or a committed file names. The environment therefore does not pin anything, and `project_set` can redirect a directory whose environment names a different project. There is no variable that merely offers a project. The committed `formio.json` is hand-authored: the server reads it and never writes it, and `project_set` records only the mapping.

Skills never interview for these URLs and never restate the guidance the server owns — the one exception is `plugin/skills/formio-mcp-setup/references/project-urls.md`, the canonical copy every other document links to instead of restating. Before its first deployment-touching call, a tool-calling skill calls the `project_get` MCP tool with `cwd` set to the user's working directory and branches on the `status` it returns (`ok` / `not-configured` / `base-url-unresolved`), recording whatever value the report names with `project_set`; a call that fails outright rather than returning a status is a broken record, not an absent one, so it is relayed rather than interviewed around. Skills do NOT shell out to `npx @formio/mcp project get` for this — the connected server answers it over the open transport with the same resolver every other tool uses. The `project get` / `project set` CLI subcommands remain, for `formio-mcp-setup`, which runs before any tool exists to call. `formio-mcp-setup` is the handoff target for a resolution failure; `formio-resource-planner` is exempt because it calls no MCP tool. Writing a URL into a user's application (`Formio.setBaseUrl`, `Formio.setProjectUrl`, `FormioAppConfig`'s `appUrl` = Project URL and `apiUrl` = Base URL) needs the values rather than a deployment, so it takes them from `project_get` when the tools are callable and from the user when they are not — never hardcoded.
Skills never interview for these URLs and never restate the guidance the server owns — the one exception is `plugin/skills/formio-mcp-setup/references/project-urls.md`, the canonical copy every other document links to instead of restating. Before its first deployment-touching call, a tool-calling skill calls the `project_get` MCP tool without `cwd` (the server takes the directory from the client's workspace root) and branches on the `status` it returns (`ok` / `not-configured` / `base-url-unresolved`), passing `cwd` afterwards only when the reported `cwdSource` is `server` or `claude-project-dir`, or to target another directory, recording whatever value the report names with `project_set`; a call that fails outright rather than returning a status is a broken record, not an absent one, so it is relayed rather than interviewed around. Skills do NOT shell out to `npx @formio/mcp project get` for this — the connected server answers it over the open transport with the same resolver every other tool uses. The `project get` / `project set` CLI subcommands remain, for `formio-mcp-setup`, which runs before any tool exists to call. `formio-mcp-setup` is the handoff target for a resolution failure; `formio-resource-planner` is exempt because it calls no MCP tool. Writing a URL into a user's application (`Formio.setBaseUrl`, `Formio.setProjectUrl`, `FormioAppConfig`'s `appUrl` = Project URL and `apiUrl` = Base URL) needs the values rather than a deployment, so it takes them from `project_get` when the tools are callable and from the user when they are not — never hardcoded.

Authentication: the MCP server uses a browser-based portal-login flow — a short-lived local Express server renders the Form.io portal login form and captures the returned JWT via a `/callback` endpoint; `formioFetch` then attaches `x-jwt-token` on every request. Skills do NOT use PKCE or API-key auth.

Expand Down
2 changes: 1 addition & 1 deletion PROCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ Naming a framework or a sub-skill skips the orchestrator, and the skills are bui

### Snapshotting before a write

The import preview advises `project_export` first — one call that captures roles, resources, forms, and actions as a portable document. It is the cheapest insurance against a same-machine-name overwrite. At form granularity, `form_revisions_list` and `form_revision_get` read the immutable published revisions of a single form where form revisions are enabled.
The import preview advises `project_export` first — one call that captures roles, resources, forms, and actions as a portable document. It is the cheapest insurance against a same-machine-name overwrite. At form granularity, `form_revision_list` and `form_revision_get` read the immutable published revisions of a single form where form revisions are enabled.

### URLs in generated code

Expand Down
39 changes: 25 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,37 +258,39 @@ One caveat: without the skills, the agent authors Form.io JSON (form schemas, ac

## MCP server tools

The bundled `@formio/mcp` server exposes these tools. Skills prefer these over raw HTTP whenever an operation is covered.
The bundled `@formio/mcp` server exposes these 23 tools. Skills prefer these over raw HTTP whenever an operation is covered.

### Forms

| Tool | Purpose |
| --- | --- |
| `form_create` | Create a new form. Use the `formio-schema` skill first to build the JSON definition. |
| `form_get` | Fetch a single form definition by ID or path. |
| `form_list` | List forms with optional filtering and pagination. |
| `form_update` | Update an existing form. Call `form_get` first, edit with `formio-schema`, then update. |
| `form_revisions_list` | List the immutable published revision summaries for a form (`_vid`, `_id`, `modified`, `user`, `_vnote`). Requires form revisions to be enabled. |
| `form_create` | Create a new form. Use the `formio-schema` skill first to build the JSON definition. On a deployment licensed for revisions, `revisions` defaults to `"original"`. |
| `form_get` | Fetch a single form definition by `_id` or path (`formIdOrPath`). `draft: true` fetches its draft instead. |
| `form_list` | List forms, one page at a time, filtered by `type` and by `tags` (forms carrying every tag given). Returns summary fields unless `select` asks for others. |
| `form_update` | Update a form with its edited JSON. Call `form_get` first, edit with `formio-schema`, then update. `draft: true` saves a draft instead of the live form. |
| `form_publish` | Publish a form's draft as its live version (`formId`, `note`). Fails with `NO_DRAFT` when the form has no draft. Needs the revisions licence (Security Module). |
| `form_revert` | Restore a prior revision's components, tags, properties and display onto the live form (`formId`, `version`, `note`). Needs the revisions licence (Security Module). |
| `form_revision_list` | List a form's revision summaries (`_id`, `_vid`, `_vnote`, `_vuser`, `created`, `modified`), newest first. Requires form revisions to be enabled. |
| `form_revision_get` | Fetch a single immutable form revision by `_vid` or revision document `_id`. |

### Roles

| Tool | Purpose |
| --- | --- |
| `role_create` | Create a new project role. |
| `role_list` | List all project roles. |
| `role_update` | Full-replacement update of a role. Include all fields you want preserved. |
| `role_create` | Create a new project role, passed as `role: { title, description?, default?, admin? }`. |
| `role_list` | List the project's roles, one page at a time. |
| `role_update` | Update a role (`roleId`, `role`). Fields left out keep their stored value. |

### Actions

| Tool | Purpose |
| --- | --- |
| `action_types_list` | List all action types available on the server. |
| `action_type_get` | Get an action type's settings schema. |
| `action_type_list` | List every action type available on the server — the whole catalog, which Form.io does not page. |
| `action_type_get` | Get an action type's settings schema. Fails with `UNKNOWN_ACTION_TYPE` for a type the catalog does not list. |
| `action_create` | Attach a new action to a form. |
| `action_list` | List actions on a form. |
| `action_list` | List the actions on a form, one page at a time. |
| `action_get` | Get a single action by ID. |
| `action_update` | Update an action. |
| `action_update` | Update an action. Fields left out keep their stored value. |
| `action_delete` | Detach an action from a form. |

### Project
Expand All @@ -304,7 +306,16 @@ The bundled `@formio/mcp` server exposes these tools. Skills prefer these over r

| Tool | Purpose |
| --- | --- |
| `hello` | Smoke-test tool. Returns a static greeting; useful for verifying MCP wiring before any authenticated call. |
| `server_status` | Report the server's name and version and how `cwd` resolves to a project, as `project_get` does. It makes no Form.io request and needs no credentials, so it separates a transport problem from an authentication or configuration one — call it first when other tools fail. |

### Conventions every tool follows

- **`cwd`.** Every tool that resolves a project takes an optional `cwd` and resolves for one directory on each call, chosen in this order: the `cwd` argument; the client's workspace roots, when the client declares the MCP `roots` capability — one root as is, and with several the roots holding a project record (a committed `formio.json` or a directory mapping, a broken one included) decide — the first of them when they all hold the same record, the next source when none holds one, and a refusal with `INVALID_ARGUMENT` listing them when they hold different records; the `CLAUDE_PROJECT_DIR` environment variable, when it is an absolute path; and last the server process's own working directory, which is fixed where the client spawned it. Roots are read once per session and again after `notifications/roots/list_changed`; a `roots/list` that fails or does not answer within 2 seconds leaves the last good list in use, and counts as no roots only when no read has succeeded. `project_get` and `server_status` report the source as `cwdSource` (`argument`, `client-root`, `claude-project-dir` or `server`). Pass `cwd`, as an absolute path, when `cwdSource` is `server` or `claude-project-dir` — both guesses at where the user is — or to target another directory — such as one application in a monorepo that keeps a `formio.json` per app.
- **`formId` and `formIdOrPath`.** `formId` is always a form's 24-character ObjectId `_id`. `formIdOrPath` takes the `_id` or the form's path, and only the reads Form.io serves by path take it: `form_get`, `form_revision_list` and `form_revision_get`. A path passed as `formId` is refused with `INVALID_ARGUMENT`; read the form's `_id` with `form_get`.
- **Lists.** `form_list`, `role_list`, `action_list` and `form_revision_list` take `limit` (default 100), `skip`, `sort` and `select`. Each returns its items under its own key (`forms`, `roles`, `actions`, `revisions`) beside `total`, the number of items the whole query matches, and `hasMore`, which is true while another page exists: call again with a larger `skip` until it is false. When Form.io does not report a total, `total` is absent and `hasMore` is true whenever the page came back full. `action_type_list` returns its `actionTypes` the same way, always with `hasMore: false`.
- **Updates overwrite what they send.** `form_update`, `role_update` and `action_update` send a PUT, which Form.io applies to the stored document: each field in the body overwrites the stored one, and a top-level field left out keeps its stored value. An array or object in the body is stored as given — it replaces the stored one whole rather than merging into it — so send every component, access entry or setting the field should keep.
- **Revision history.** A `form_create` or `form_update` that would save without revision history — the deployment is not licensed for revisions, the body sets `revisions: ""`, or an update targets a form whose revisions are off — is refused with `HISTORY_NOT_ACCEPTED` and writes nothing, unless the call passes `acceptNoHistory: true`. The server never prompts the user itself: the agent relays the refusal, and retries with the user's answer. Drafts, `form_publish` and `form_revert` need the revisions licence and are refused with `LICENSE_REQUIRED` without it.
- **Errors.** A failed call returns `isError: true`, with text that starts with the error's code in brackets — `[NOT_FOUND] …` — followed by the message, and `_meta["io.form/error"]: { code, status?, body? }`, where `status` is Form.io's HTTP status and `body` its response body, truncated to 2,000 characters. An error result carries no `structuredContent`. The codes are `NOT_CONFIGURED`, `BASE_URL_UNRESOLVED`, `CONFIG_UNREADABLE`, `INVALID_ARGUMENT`, `AUTH_REQUIRED`, `FORBIDDEN`, `NOT_FOUND`, `VALIDATION_FAILED`, `LICENSE_REQUIRED`, `HISTORY_NOT_ACCEPTED`, `NO_DRAFT`, `UNKNOWN_ACTION_TYPE`, `REDIRECTED`, `UPSTREAM_ERROR`, `NETWORK_ERROR` and `INTERNAL`. An argument that fails the tool's input schema is reported by the MCP SDK as plain text, without a code.

---

Expand Down
2 changes: 1 addition & 1 deletion llms-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,6 @@ npx -y @formio/mcp@0.14.1 project get --cwd <absolute path>

## Verifying the install

Call the `hello` tool — it needs no authentication and confirms the server is reachable. Then call `form_list`, which triggers the login flow on first use.
Call the `server_status` tool — it needs no authentication, confirms the server is reachable, and reports which project the working directory resolves to. Then call `form_list`, which triggers the login flow on first use.

If `form_list` fails with a missing-configuration error, nothing is recorded for that working directory yet — the error names the value to ask for and the `project_set` call that records it, so relay it rather than reaching for an environment variable, which any mapping overrides anyway. Requires Node.js 20 or newer.
2 changes: 2 additions & 0 deletions openspec/changes/freeze-1-0-tool-surface/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-tdd
created: 2026-10-09
Loading
Loading