diff --git a/.changeset/freeze-1-0-tool-surface.md b/.changeset/freeze-1-0-tool-surface.md new file mode 100644 index 00000000..0fc24ed6 --- /dev/null +++ b/.changeset/freeze-1-0-tool-surface.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index b306e3f6..5afccdd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/PROCESS.md b/PROCESS.md index 8ef6d7bf..5a60d542 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -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 diff --git a/README.md b/README.md index 436c93d2..ea959e3a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. --- diff --git a/llms-install.md b/llms-install.md index 92318bef..b0c64c37 100644 --- a/llms-install.md +++ b/llms-install.md @@ -102,6 +102,6 @@ npx -y @formio/mcp@0.14.1 project get --cwd ## 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. diff --git a/openspec/changes/freeze-1-0-tool-surface/.openspec.yaml b/openspec/changes/freeze-1-0-tool-surface/.openspec.yaml new file mode 100644 index 00000000..0d9236cf --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-tdd +created: 2026-10-09 diff --git a/openspec/changes/freeze-1-0-tool-surface/design.md b/openspec/changes/freeze-1-0-tool-surface/design.md new file mode 100644 index 00000000..3f5dab47 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/design.md @@ -0,0 +1,99 @@ +## Context + +`@formio/mcp` registers 21 tools. Measured on `main` with `registerAllTools` and an in-memory client: `tools/list` serializes to 68,551 characters; the `cwd` argument description is 644 characters and appears on 20 tools (~12.9k); `project_set`'s entry alone is 7,294; the server instructions are 5,161. + +Verified against the Form.io source (`~/Documents/formio/modules/nirvana`): + +- **Path aliases.** `apps/formio/src/middleware/alias.js` rewrites `/{path}/…` to `/form/{id}/…` when the remainder starts with a reserved sub-route. Core reserves `action` but not `v`, `draft` or `actions`; Enterprise (`apps/formio-server/config.js:96-147`) replaces the list and adds `v` and `draft`, but not `actions`. An unknown alias returns **400 "Invalid alias"**. So `/{path}`, `/{path}/action[/{id}]` resolve everywhere, `/{path}/draft` and `/{path}/v[/{vid}]` resolve on Enterprise, and `/{path}/actions[/{name}]` never resolves. +- **Paging.** resourcejs (`packages/resourcejs/Resource.js`) defaults `limit` to 10 with no maximum (607-622), applies `sort` and `select` as given (334-353, 648-649), and reports the total on every index response as `Content-Range: {from}-{to}/{total}` (or `*/{total}` when empty) via `node-paginate-anything`; a `skip` at or past the total returns **416**. `GET /role`, `GET /form/{id}/action` and the form-revision index (`FormRevisionResource.js:66-74`, no default sort) are resourcejs indexes. `GET /form/{id}/actions` is a plain `res.json` array (`actions.js:758`), not paged. +- **Tag filter.** `tags=a,b` compares against the literal string `"a,b"`; `tags__in` is any-of, `tags__all` is all-of (`Resource.js:456-500`). +- **Output schemas.** The SDK wraps each raw output shape in a closed object: every tool publishes `additionalProperties: false` at the top level. With the SDK client, a `form_get` result carrying `_vid` or `pdfComponents` is rejected ("must NOT have additional properties"), and `settings: null` fails server-side validation. +- **Consent.** `form_create` and `form_update` call `gateRevisionsLicense`, which on an unlicensed deployment asks once per deployment (elicitation, or a local browser page from `revisions/browser-prompts.ts`) and persists the answer in `~/.formio/revisions-license-consent.json`; `form_update` then calls `gateRevisionsTracking` (`revisions/tracking.ts`), which asks per form when its stored `revisions` is off. + +This change follows `confine-requests-to-project-origin` (PR #88), whose path-argument rule, redirect handling and API-key binding it builds on. + +## Goals / Non-Goals + +**Goals:** + +- One naming rule, one form-addressing rule, one list contract, one error contract, and output schemas that never reject a valid response — frozen as the 1.0 surface. +- No tool call ever waits on an out-of-band prompt. +- `tools/list` at or below 42,000 characters with no rule an agent acts on removed. +- Every skill, README and test that names a changed tool or argument updated in the same change. + +**Non-Goals:** + +- 401 handling, request timeouts, browser launch, logout — `harden-auth-and-errors`. The licence probe's cache and timeout are settled here (Decision 4), because which licence answers a refusal may name is part of the error contract. +- Atomic local-state writes, mapping ancestor lookup — `harden-local-state-and-release`. +- Patch semantics for updates: `form_update`, `role_update` and `action_update` stay a PUT in 1.0, documented as Form.io applies it — resourcejs loads the stored document and sets the body onto it, so each field sent overwrites the stored one, a top-level field left out keeps its stored value, and an array or object sent replaces the stored one whole rather than merging. +- Cutting the 1.0.0 version — the plan's release step. + +## Decisions + +**1. Renames are clean: no aliases.** +`action_types_list` → `action_type_list`, `form_revisions_list` → `form_revision_list`, `hello` → `server_status`. The old names are not registered. CLAUDE.md asks for clean breaks unless compatibility is requested, and the major version is the signal. + +**2. `formId` always means an ObjectId; `formIdOrPath` means either.** +The routes decide: a read whose route resolves a path alias (`form_get`, `form_revision_list`, `form_revision_get`) takes `formIdOrPath`; writes and every action tool take `formId`. Action list/get/update/delete would resolve a path through the reserved `action` sub-route, but the catalog (`actions`) does not, and one argument meaning one thing across the seven action tools beats path support on five of them. A path passed as `formId` gets `INVALID_ARGUMENT` naming `form_get` as the way to read the `_id`. On an Open Source deployment `formIdOrPath` with a path fails for `draft` and `v` sub-routes with Form.io's 400, mapped to `NOT_FOUND` with the body — those routes are Enterprise-only anyway. + +**3. `form_publish` and `form_revert` reuse `revisions/flows.ts`.** +The flows already exist as `publishDraft` / `revertToVersion`; the tools are thin registrations over them, each with only the arguments it needs. `form_update`'s draft mode changes from "refuse non-allowlisted keys" to "pick the allowlisted keys": `pick(form, DRAFT_FIELDS)`. A draft save carrying none of them is still refused (`INVALID_ARGUMENT`), so an empty draft is never written by accident. + +**4. `acceptNoHistory` replaces both prompts; the per-form check stays, without the prompt.** +Removed: `revisions/browser-prompts.ts`, the elicitation paths, the in-memory approved-form set, and the persisted licence-consent file with its read/write helpers. Kept: `checkRevisionsLicensed`, `stripRevisions`, the note prefix, and the per-form read of the stored `revisions` setting (one GET before a standard update), now feeding a refusal instead of a prompt. The refusal message is written for the agent to relay: why the save would carry no history, the two ways forward (`acceptNoHistory: true`, or `form.revisions` on a licensed deployment). Elicitation is not kept even where a client supports it: one path for every client is easier for skills to describe and test, and the chat already is the channel to the user. +*Alternative considered:* elicitation with a refusal fallback — rejected; two behaviours by client capability is what made the prompt path hard to reason about. + +The licence probe (`GET {baseUrl}/config.js`, anonymous, bounded by a 10-second timeout) has three outcomes. `config.js` fetched and parsed: `sac = true` is licensed, anything else unlicensed — both cached per deployment. No response, a timeout, or a non-2xx status is unknown and is cached for 60 seconds, so a run of writes against a deployment that is not answering does not re-probe on each one, and a call after that probes again. With an unknown licence, `form_update` with `draft: true`, `form_publish` and `form_revert` refuse with the probe's own code (`NETWORK_ERROR` for no response, `UPSTREAM_ERROR` for an error status) and a message saying the licence could not be determined and naming the URL — never `LICENSE_REQUIRED`. Standard creates and updates send the caller's body as written: `revisions` is never stripped and the save is never refused on the licence's account. They are checked as on a licensed deployment otherwise — a body setting `revisions` to an empty value still needs `acceptNoHistory`, and `form_update` still reads the stored form's `revisions` — while `form_create` adds its `revisions: "original"` default only where the deployment is definitely licensed. An unknown licence therefore never removes or silently disables history. With no Base URL the probe cannot run at all; that case keeps its `BASE_URL_UNRESOLVED` refusal. + +**5. Errors: a typed error at the source, one mapper at the edge.** +`formioFetch` throws `FormioApiError { status, url, body }` (body read as text, truncated to 2,000 characters, Form.io's `message` / `name` extracted into the prose when the body is JSON) and `FormioNetworkError { url, cause }` for a request with no response. Resolution errors already have classes (`ProjectNotConfiguredError`, `ProjectMapUnreadableError`, `CommittedConfigUnusableError`, base-URL unresolved); each gains a `code`. `toMcpError` maps every known class to `{ code, status?, body? }` and anything else to `INTERNAL`, and returns it in the result's `_meta["io.form/error"]`, with the text led by `[CODE] ` ahead of any notes and the unchanged message. The payload is not `structuredContent`: the SDK client (`@modelcontextprotocol/sdk` 1.30.1 through 1.32.1, `client/index.js` `callTool`) validates `structuredContent` against the tool's success `outputSchema` whenever it is present, even on an `isError` result — only the server skips validation for errors — and the MCP spec requires structured results to conform to the `outputSchema`. Every list, acknowledgement and project tool declares required fields, and `project_get`'s `status` is a string enum, so an error payload there is rejected as a protocol error. `_meta` is not validated, and the text prefix keeps the code visible to clients that surface only text. The `code` set is the one in the `tool-error-contract` spec; `REDIRECTED` comes from PR #88's redirect refusal. A status the mapping does not name (405, 409, 429, …) maps to `UPSTREAM_ERROR`; an empty body is omitted from `body`; a short plain-text body (≤200 characters, not HTML, such as Form.io's "Invalid alias") goes into the prose as a JSON `message` does. The error classes live in `src/tool-errors.ts` rather than `formio-client.ts`, so tool tests that mock the client module keep working. Group 1 gives codes to the resolution errors, the client's errors, the relative-`cwd` refusal and the out-of-project path refusal (`INVALID_ARGUMENT`); `NO_DRAFT` and `UNKNOWN_ACTION_TYPE` are wired by the groups that change those tools (5 and 7), and the browser-login failures that leave the call without a credential (no browser, login timeout) are `AUTH_REQUIRED` with their messages unchanged, while a login port that cannot be bound is `INTERNAL` with the cause in the message, and a login form that cannot be resolved keeps the code of the error behind it (`INTERNAL` when that error has none). `validateToken` sends through the same wrapper as `formioFetch`, so a refused connection there is `NETWORK_ERROR` naming the cause and URL. `formioErrorText` also reads `errors` as an array — of strings, as resourcejs answers a missing document (`{ status: 404, errors: ['Resource not found'] }`), or of objects carrying `message`. +SDK-level input validation (zod failures before the handler runs) is produced by the SDK as text with no `[CODE]` prefix and no `_meta` error; it keeps naming the argument (PR #88 made the path messages actionable) but carries no `code`. Wrapping every handler to re-validate would duplicate the SDK's check. Where a refusal is the common case, the check moves into the handler instead, so it carries `INVALID_ARGUMENT`: `formId` and `role_update`'s `roleId` (a shared ObjectId check), and `project_set`'s `projectUrl` / `baseUrl`, which its write plan already validates. `project_set` routes every failure through `toMcpError` — its refusals `INVALID_ARGUMENT`, an unreadable project map `CONFIG_UNREADABLE`. A test registers every tool and drives one error path per tool to hold the `[CODE] ` prefix and `_meta` code. + +**6. The list contract reads `Content-Range`.** +`formioFetch` gains a `withMeta` option returning `{ data, total }`, parsing `Content-Range` (`a-b/N` or `*/N`). A 416 from a `skip` past the end is answered with `{ items: [], total: N, hasMore: false }` using the `*/N` it carries. Default `limit` is 100: Form.io imposes no maximum, and 100 keeps a typical project's roles, actions and forms in one page without flooding context with full form definitions (`form_list` selects summary fields by default already). The list result's item key stays per tool (`forms`, `roles`, `actions`, `revisions`, `actionTypes`), with `total` and `hasMore` beside it. Where a response reports no total, `total` is the items seen so far (`skip` plus the page) and `hasMore` is whether the page came back full: a full page may have more behind it. `form_revision_list` excludes the draft — Form.io stores it as a revision whose `_vid` is `"draft"` — in the query (`_vid__ne=draft`; `_vid` is Mixed, so resourcejs passes the string through), so `total` counts the same rows the pages hold; `form_revert` refuses `version: "draft"` with `INVALID_ARGUMENT` pointing to `form_publish`. + +**7. Output schemas are passed as open zod objects.** +Each tool's `outputSchema` becomes `z.looseObject(shape)` instead of a raw shape, and documented fields that Form.io stores as Mixed (`settings`, `properties`, `condition`, `owner`, `access`) accept `null`. A test lists every tool and asserts no published schema carries `additionalProperties: false`, and replays recorded Form.io documents (with `_vid`, `pdfComponents`, `controller`, `esign`, `null` settings) through each read and write tool's schema. + +**8. Description budget: shorten what repeats, move what explains.** +A description stays where a caller acts on it, and everything else moves to where it is read once. The `cwd` description is ≤100 characters ("Optional. Directory whose project to use; defaults to the client's workspace root."), because it repeats on all 23 tools; when to pass it — `cwdSource` `"server"` or `"claude-project-dir"`, or to target another directory — and the order a directory is chosen in stay in the server instructions every client receives once. Output-schema field descriptions are kept only on the fields a caller branches on (`project_set`'s `ok`, `status`, `total`, `hasMore`, `remedy`, `remedy.supply`); every other output field is typed and undescribed, its meaning in the server instructions, the report's `message`, or a code comment. A Form.io document's output schema stays open and declares only the fields a caller acts on — those the skills and tools read — so `created`, `owner`, `settings`, `machineName` and the like pass through undeclared; a form's `access` / `submissionAccess` stay declared because the auth skills read them, typed as Mixed because their full array-or-null shape cost more than the rest of the document on each of the seven tools that return a form. Annotation titles are at most four words. Input arguments the server defines (`cwd`, `note`, `draft`, `acceptNoHistory`, `formId`, `formIdOrPath`, `version`, `limit`, `skip`, `sort`, `select`, `tags`, `projectUrl`, `baseUrl`) keep theirs; the fields nested inside a form, action or role body are Form.io's own and carry none, left to the `formio-schema` and `formio-actions` skills — except `form.revisions` on `form_create` / `form_update`, which `acceptNoHistory` depends on. `project_set` keeps every rule it enforces and drops the rationale (to the README's "Why project_set works this way"). Measured on the serialized `tools/list` for 23 tools: 80,656 characters at the start of this group (the new tools and open schemas had grown it from 68,551); 49,661 after the first pass (descriptions 5,523; input schemas 19,769, of which argument descriptions ≈11,000 and the `cwd` property ≈5,900; output schemas 18,779, of which field descriptions ≈4,800; annotations 2,708); 41,830 after the description rule (descriptions 5,247; input schemas 15,702, argument descriptions ≈7,000, `cwd` ≈3,100; output schemas 15,291, field descriptions ≈1,400; annotations 2,708); 37,566 after the document rule (descriptions 5,247; input schemas 15,702, argument descriptions ≈7,000, `cwd` ≈3,100; output schemas 11,047, field descriptions ≈1,500; annotations 2,688). The test budget stays 42,000, leaving about 4,400 characters of headroom for the tools still to come; the ≤100-character `cwd` description, the output-field allow-list, the declared document fields, the four-word titles, the server-defined arguments and the body-field rule are each pinned by a test. Each removal is checked against the skills: no skill relies on description text it now omits (the skills carry their own guidance — the shared preflight paragraph states the `cwdSource` rule itself — and call `project_get`). + +**9. Boolean env vars: one `readBooleanEnv`.** +`FORMIO_INSECURE_TLS` already accepted `true`/`1`; `FORMIO_FORCE_BROWSER` accepted only `1`. Both use the same function. (`CI` is a third-party convention read with its own rule and is left alone.) + +**10. `exports` exposes only `package.json`.** +Nothing imports `@formio/mcp` as a library (`scripts/build-plugin.ts` and `build-mcpb.ts` bundle from `src/stdio.ts`). The `bin` entry is unaffected by `exports`. + +**11. `~/.formio/projects.json` keeps its entry format.** *(Confirmed by the maintainer, 2026-10-09.)* +A rename of the `env` block's keys was considered and set aside. It is a machine-local file the server writes, not tool surface, and renaming its keys needs either a reader for the old shape (a compatibility shim CLAUDE.md rules out) or invalidating every user's existing mappings. Its current shape is documented as-is. `formio.json` already ignores unknown keys, so `$schema` works today; no change. + +**12. CLI flags and exit codes are documented, not changed.** +`project get [--cwd]`, `project set [--project-url] [--base-url] [--force] [--reset] [--cwd]`; exit 0 OK, 1 not configured, 2 failed, 3 base URL unresolved. The README table becomes the 1.0 reference. + +**13. Skills move with the tools.** +The shared preflight paragraph (15 skills, identical text enforced by `shared-prose-stays-identical.test.ts`) lists the writing tools; it gains `form_publish`, `form_revert`, `role_update`, `action_update`, `action_delete`. `formio-actions` names `action_type_list`; `formio-api`'s `project-form-revisions.md` is rewritten for `form_revision_list`, `form_publish`, `form_revert` and `acceptNoHistory` (its consent sections go); every `role_create` example uses the nested `role`. A test asserting that every tool name a skill mentions is registered belongs to `drift-guards`, but a grep-based check for the three old names runs in this change. + +**14. This change carries the major changesets.** +`@formio/mcp` and `@formio/ai` get `major` here, because this is the change that breaks the surface; changes landing after it add `minor` / `patch` changesets that fold into the same version when the plan's release step runs `changeset version`. + +**15. The directory comes from the client's roots when no `cwd` is passed.** *(Added before merge at the maintainer's request.)* +Every tool already resolved against `process.cwd()` when given no `cwd`, but a client spawns the server wherever it likes — Claude Code in the project, a desktop app often in `/` — so the skills told agents to pass `cwd` on every call. MCP `roots` lets the client say which directories it is working in. The order is: `cwd` argument; the client's roots (one root as is; with several, the roots that resolve a project record decide — the first of them when they all resolve the same record, the next source when none does, `INVALID_ARGUMENT` listing them when they resolve different records); `CLAUDE_PROJECT_DIR` (documented by Claude Code for spawned servers); the process's working directory. Roots are read once per session with a `roots/list` request, with a 2-second timeout, and re-read after `notifications/roots/list_changed`. Only Claude Code documents its roots (launch directory plus added directories, change notifications from v2.1.203); other clients are handled by the fallbacks. `project_get` and `server_status` report `cwdSource`, and the skills' preflight calls `project_get` without `cwd` and passes `cwd` afterwards only when the report says `server` or `claude-project-dir`. The `cwd` argument stays, optional, for monorepos with a `formio.json` per app and for clients without roots. The request goes through `server.request` with a lenient result schema rather than the SDK's `listRoots`, whose schema rejects the whole answer when one root is not `file://`. A failed or timed-out read keeps the last good list in use (and counts as no roots only when no read has succeeded), until the next `list_changed`, so a client that never answers costs the timeout once rather than on every call. Several roots compare by the record each resolves — committed file path or mapping key, a broken record included — not by how many found one, so roots inside one repository are one project, roots with no record fall through (an environment-only launch resolves as before), and root paths are normalized before de-duplication. `CLAUDE_PROJECT_DIR` is a guess like the server's own directory: it reports `claude-project-dir` but earns the same pass-`cwd` note. One provider per `McpServer` (created on first use) holds the cache and the `list_changed` handler; tools await it for `{ dir, source }` and pass the resolver `isNamedDirectory(directory)` and the name of a guessed directory, so the CLI's resolution is unchanged. +*Alternatives considered:* removing `cwd` — rejected, a monorepo with one `formio.json` per app and clients that report no roots both need it; picking the first of several roots — rejected, only Claude Code documents an order. + +## Risks / Trade-offs + +- [Agents and scripts calling old names fail] → The tool list is read at connect, so a client sees the new names immediately; skills and READMEs ship in the same release; the changeset lists every rename. +- [The extra GET before a standard update] → It existed already (the tracking gate); unchanged cost. +- [An error result validated against the tool's `outputSchema`] → Only the server skips output validation for `isError` results; the SDK client validates `structuredContent` whenever it is present (verified in `@modelcontextprotocol/sdk` 1.30.1 and 1.32.1, client `index.js`). Error results therefore carry no `structuredContent` — the payload is in `_meta["io.form/error"]` — and a test asserts an error from `form_create` and `role_list` passes through a validating client. +- [Default limit 100 returns large form lists] → `form_list` selects summary fields unless `select` overrides; `limit` is caller-tunable. +- [Trimming descriptions changes tool choice] → Only repetition and rationale are removed. The eval harnesses do not exercise MCP tool descriptions (the planner calls no MCP tool; the resources harnesses grade generated code), so instead every rule a trimmed description pointed to is checked to remain in the server instructions or `project_set` (pinned by `tool-description-budget.test.ts`), and `plugin/skills/` is checked for reliance on removed wording. + +## Migration Plan + +1. Merge PR #88; rebase this branch on `main`. +2. Land the change with major changesets; the release step produces 1.0.0. +3. Rollback is a revert of the merge; no on-disk format changes (the licence-consent file is simply no longer read). + +## Open Questions + +None outstanding. Resolved 2026-10-09: `projects.json` keeps its format (Decision 11); the default page size is 100 (Decision 6). diff --git a/openspec/changes/freeze-1-0-tool-surface/proposal.md b/openspec/changes/freeze-1-0-tool-surface/proposal.md new file mode 100644 index 00000000..ae884193 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/proposal.md @@ -0,0 +1,66 @@ +## Why + +1.0.0 makes the `@formio/mcp` tool surface a contract: after it, a renamed tool, a re-shaped argument, or a tightened output schema is a major release. Today that surface is inconsistent in ways an agent pays for on every call, and some of it is wrong: + +- Names mix singular and plural (`form_list` beside `action_types_list`, `form_revisions_list`), and `hello` reports nothing a caller needs. +- `form_update` folds four operations behind flags: publish and revert ignore the `form` body yet require it, and draft refuses fields `form_get` returns. +- Every list tool returns only the first page: `role_list`, `action_list` and `form_revisions_list` send no `limit`, so Form.io's default of 10 truncates them silently, revisions come back oldest first, `count` is the page length, and `form_list`'s multi-tag filter matches nothing. +- Every output schema is closed at the top level, so a spec-compliant client rejects `form_get`'s response whenever Form.io returns a field the schema does not list (`_vid`, `pdfComponents`, `controller`, `esign`, …) — a successful read or write reported as a failure. +- Form writes on a deployment without revisions can stop mid-call for an out-of-band consent prompt (elicitation, or a local browser page when the client has none). +- Errors are prose only, and drop Form.io's response body. +- The tool list costs about 68.5k characters of context; the 644-character `cwd` description alone repeats on 20 tools. + +Fixing these after 1.0 would each be a breaking change. Fixing them now is one. + +## What Changes + +- **BREAKING** — Renames: `action_types_list` → `action_type_list`, `form_revisions_list` → `form_revision_list`, and `hello` → `server_status`, which reports the server version and how the caller's `cwd` resolves (or why it does not) without making a Form.io request. +- **BREAKING** — `form_publish` (`formId`, `note`) and `form_revert` (`formId`, `version`, `note`) are new tools; `form_update` loses its `publish`, `revert` and `version` arguments. `form_update` keeps the full update and `draft: true`; in draft mode it saves the draft fields from the body it is given and ignores the server-owned ones `form_get` returns, instead of refusing them. +- **BREAKING** — Form-addressing arguments follow one rule: `formId` is always a 24-character ObjectId; `formIdOrPath` accepts an ObjectId or a form path. Reads that Form.io serves by path (`form_get`, `form_revision_list`, `form_revision_get`) take `formIdOrPath`; writes and every action tool take `formId` (the action-type catalog route does not accept a path). +- **BREAKING** — `role_create` takes a nested `role` object, like `role_update`. +- **BREAKING** — One list contract for `form_list`, `role_list`, `action_list`, `form_revision_list`: `limit` (default 100), `skip`, `sort`, `select` arguments, and `total` + `hasMore` in the result, read from Form.io's `Content-Range` header. `count` is removed. `form_revision_list` sorts newest first and returns compact metadata. `form_list`'s `tags` matches forms carrying all the given tags. `action_type_list` returns the whole catalog, which Form.io does not page. +- **BREAKING** — The revisions consent prompts (elicitation and the local browser page) are removed. `form_create` and `form_update` take an optional `acceptNoHistory: boolean`; when a write would save without revision history and it is not `true`, the tool refuses with a message the agent relays to the user, and the agent retries with the user's answer. The persisted consent file is removed. +- **BREAKING** — Tool errors keep their prose message, led by a `[CODE]` prefix, and add `_meta["io.form/error"]: { code, status?, body? }`, with `code` from a fixed set and Form.io's response body (truncated) included. Error results carry no `structuredContent`. +- Output schemas are open at every level: fields Form.io returns beyond the documented ones pass through, and documented fields accept the types Form.io actually stores (including `null`). +- `cwd` stops being something every call must carry. When a call passes no `cwd`, the server resolves the project against the client's workspace root (MCP `roots`), then `CLAUDE_PROJECT_DIR`, then its own working directory, and `project_get` / `server_status` report which source was used (`cwdSource`). `cwd` stays an optional argument for targeting another directory or a client that reports no roots. Skills run their preflight without `cwd` and pass it only when the report says the server fell back to its own directory. +- Boolean environment variables share one parser (`true` / `1`, case-insensitive, trimmed): `FORMIO_INSECURE_TLS`, `FORMIO_FORCE_BROWSER`. +- `@formio/mcp`'s `package.json` gains `exports` with no library entry: the package is its `formio-mcp` binary. +- Tool descriptions are trimmed to a measured budget: the `cwd` description is shortened (its full guidance stays in the server instructions and `project_set`), and `project_set`'s description loses its rationale and history, keeping every rule a caller acts on. A test pins the `tools/list` size. +- The CLI's `project get` / `project set` flags and exit codes (0, 1, 2, 3) are documented as part of the 1.0 surface, unchanged. +- Skills, READMEs and tests are updated for every renamed tool and changed argument in the same change. +- Changesets: **major** for `@formio/mcp` and `@formio/ai`. + +## Capabilities + +### New Capabilities + +- `form-publish`: the `form_publish` tool — publish a form's draft as its live version. +- `form-revert`: the `form_revert` tool — restore a prior revision's allowlisted fields onto the live form. +- `server-status`: the `server_status` tool, replacing `hello`. +- `tool-list-contract`: the paging arguments and the `total` / `hasMore` result every list tool shares. +- `tool-error-contract`: the structured error every tool returns, and its `code` set. +- `tool-output-schemas`: output schemas are open, so a valid Form.io response is never reported as a validation failure. +- `tool-description-budget`: the measured size budget for `tools/list` and the rules for what a description keeps. +- `project-directory-resolution`: the order in which a call's directory is chosen when no `cwd` is passed (client roots, `CLAUDE_PROJECT_DIR`, the server's directory) and the `cwdSource` field that reports it. +- `revisions-history-acceptance`: the `acceptNoHistory` argument that replaces the consent prompts. + +### Modified Capabilities + +- `form-update`: drops the publish/revert flags; draft accepts `form_get`'s output; the tracking gate becomes `acceptNoHistory`. +- `form-create`: the unlicensed path uses `acceptNoHistory` instead of a prompt. +- `form-revisions`: `form_revisions_list` becomes `form_revision_list` (paged, newest first); the license and per-form gates refuse instead of prompting. +- `form-list`: joins the list contract; tag filter matches all tags. +- `role-create`: nested `role` argument. +- `role-list`: joins the list contract. +- `action-crud`: `action_list` joins the list contract; `formId` is an ObjectId on every action tool. +- `action-types-discovery`: `action_types_list` becomes `action_type_list`. +- `project-map-routing`: the scenario that names `hello` names `server_status`. +- `server-config`: one boolean parser for every boolean environment variable; the package publishes no library entry. + +## Impact + +- Code: `packages/mcp-server/src/tools/*` (renames, new `form_publish` / `form_revert` / `server_status`, list contract, `role_create`), `src/output-schemas.ts`, `src/mcp-responses.ts` and `src/formio-client.ts` (error contract, `Content-Range`), `src/revisions/*` (`browser-prompts.ts` and `tracking.ts` removed; license consent persistence removed), `src/config.ts` and `src/formio-client.ts` (boolean parsing), `src/project-resolver.ts` (`cwd` description), `src/tools/project_set.ts` (description), `src/server.ts` (instructions), `package.json` (`exports`), `scripts/build-mcpb.ts` (smoke call to `hello`). +- Skills: the shared preflight paragraph in 15 skills (names the writing tools), `formio-actions` (`action_types_list`), `formio-api` references (`form_revisions_list`, publish/revert, consent sections, `role_create`), `formio-auth` and planner references naming `role_create`, `formio-sdk` roles reference. `shared-prose-stays-identical.test.ts` requires every copy of the preflight paragraph to change together. +- Docs: root `README.md`, `packages/mcp-server/README.md`, `plugin/README.md`, `server.json`. +- Clients: any agent or script calling the renamed tools, `form_update`'s `publish` / `revert`, `role_create`'s flat fields, or reading `count` must move to the new surface. `~/.formio/revisions-license-consent.json` is no longer read. +- Release: ships with a major changeset for both packages; the 1.0.0 version is cut by the release step of the plan. diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/action-crud/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/action-crud/spec.md new file mode 100644 index 00000000..aea8f60a --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/action-crud/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: action_list pages a form's actions + +`action_list` SHALL take `cwd`, `formId`, and the shared list arguments and SHALL return the shared list result of actions. + +#### Scenario: More actions than Form.io's default page + +- **WHEN** a form has 11 actions and `action_list` is called with no paging arguments +- **THEN** all 11 are returned with `total: 11` + +### Requirement: Action tools address forms by ObjectId + +Every action tool (`action_list`, `action_get`, `action_create`, `action_update`, `action_delete`, `action_type_list`, `action_type_get`) SHALL take `formId` as a 24-character ObjectId and refuse any other value with code `INVALID_ARGUMENT`, because Form.io's action-type catalog route does not resolve a form path. + +#### Scenario: A form path is refused with the remedy + +- **WHEN** `action_list` is called with `formId: "user/login"` +- **THEN** the result has code `INVALID_ARGUMENT` and the message tells the agent to read the form's `_id` with `form_get` + +## REMOVED Requirements + +### Requirement: action_list retrieves actions configured on a form + +**Reason**: It sent no `limit`, so Form.io's default of 10 truncated the result; replaced by the shared list contract. +**Migration**: Read `total` and `hasMore` instead of `count`. diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/action-types-discovery/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/action-types-discovery/spec.md new file mode 100644 index 00000000..08dc1012 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/action-types-discovery/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: action_type_list lists the action types a form supports + +The server SHALL register `action_type_list` (replacing `action_types_list`), taking `cwd` and `formId`, and returning the whole action-type catalog Form.io reports for that form in the shared list result shape. + +#### Scenario: Listing action types + +- **WHEN** `action_type_list` is called for a form +- **THEN** it returns each type's `name`, `title` and `description`, with `hasMore: false` + +## REMOVED Requirements + +### Requirement: action_types_list tool is registered + +**Reason**: Renamed to `action_type_list` for singular tool names. +**Migration**: Call `action_type_list`. + +### Requirement: action_types_list retrieves available action types + +**Reason**: Superseded by `action_type_list`. +**Migration**: Call `action_type_list` with the same `formId`. diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/form-create/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/form-create/spec.md new file mode 100644 index 00000000..c5cb3c98 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/form-create/spec.md @@ -0,0 +1,20 @@ +## MODIFIED 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 create SHALL proceed only when the caller passes `acceptNoHistory: true`; otherwise it SHALL refuse with code `HISTORY_NOT_ACCEPTED` as the `revisions-history-acceptance` capability describes. + +#### 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 with `acceptNoHistory: true` +- **THEN** the POST body does not include `revisions` diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/form-list/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/form-list/spec.md new file mode 100644 index 00000000..49663179 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/form-list/spec.md @@ -0,0 +1,18 @@ +## ADDED Requirements + +### Requirement: form_list pages forms and filters by tags + +`form_list` SHALL take `cwd`, an optional `type` (`form` or `resource`), an optional `tags` array, and the shared list arguments, and SHALL return the shared list result of form summaries. `tags` SHALL match forms that carry every given tag. + +#### Scenario: Two tags + +- **WHEN** `form_list` is called with `tags: ["crm", "intake"]` +- **THEN** the request filters with `tags__all=crm,intake` +- **AND** only forms carrying both tags are returned + +## REMOVED Requirements + +### Requirement: form_list retrieves form summaries + +**Reason**: Replaced by the shared list contract (default limit 100, `total`, `hasMore`) and the all-tags filter. +**Migration**: Read `total` and `hasMore` instead of `count`; page with `skip`. diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/form-publish/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/form-publish/spec.md new file mode 100644 index 00000000..b075cd52 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/form-publish/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: form_publish publishes a form's draft + +The server SHALL register a `form_publish` tool taking `cwd`, `formId` (a 24-character ObjectId), and a required `note`. It SHALL read the form's current draft, refuse with code `NO_DRAFT` when none exists, and otherwise PUT the live form overlaid with the draft's allowlisted fields (`components`, `settings`, `tags`, `properties`, `controller`, `esign`, `display`), recording `note` as the revision note. It SHALL return the published form. It SHALL take no form body. On a deployment without the revisions licence it SHALL refuse with code `LICENSE_REQUIRED`. + +#### Scenario: Publishing a draft + +- **WHEN** `form_publish` is called with a `formId` whose form has a draft and `note: "Add phone field"` +- **THEN** the live form is updated with the draft's allowlisted fields and the note +- **AND** the tool returns the published form + +#### Scenario: No draft to publish + +- **WHEN** `form_publish` is called for a form with no draft +- **THEN** the tool returns `isError: true` with `_meta["io.form/error"].code` `NO_DRAFT` +- **AND** no PUT is sent + +#### Scenario: No form body is accepted + +- **WHEN** `form_publish` is listed +- **THEN** its input schema has `cwd`, `formId` and `note`, and no `form` argument diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/form-revert/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/form-revert/spec.md new file mode 100644 index 00000000..e0f05b5d --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/form-revert/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: form_revert restores a prior revision + +The server SHALL register a `form_revert` tool taking `cwd`, `formId` (a 24-character ObjectId), `version` (a revision `_vid` or revision `_id`, checked by the shared path-argument rule), and a required `note`. It SHALL read that revision and the live form, and PUT the live form overlaid with the revision's allowlisted fields (`components`, `tags`, `properties`, `display`), recording `note` as the revision note. It SHALL return the updated form. It SHALL take no form body. On a deployment without the revisions licence it SHALL refuse with code `LICENSE_REQUIRED`. It SHALL NOT restore the form's draft: a `version` of `draft`, and any `version` whose fetched revision has `_vid` `"draft"` (Form.io resolves `latest` by sorting on `-_vid`, where the draft sorts first, and resolves a 24-character `version` as a revision id, the draft's included), SHALL be refused with code `INVALID_ARGUMENT` naming `form_publish`, and no PUT SHALL be sent. + +#### Scenario: Reverting to a revision + +- **WHEN** `form_revert` is called with `formId`, `version: "3"` and `note: "Reverted to version 3"` +- **THEN** the live form is updated with revision 3's allowlisted fields and the note + +#### Scenario: An unknown revision is reported + +- **WHEN** `form_revert` is called with a `version` the form does not have +- **THEN** the tool returns `isError: true` with `_meta["io.form/error"].code` `NOT_FOUND` +- **AND** no PUT is sent + +#### Scenario: A version that resolves to the draft is refused + +- **WHEN** `form_revert` is called with `version: "latest"` on a form that has a draft, or with the draft revision's own `_id` +- **THEN** the tool returns `isError: true` with `_meta["io.form/error"].code` `INVALID_ARGUMENT` and a message naming `form_publish` +- **AND** no PUT is sent diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/form-revisions/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/form-revisions/spec.md new file mode 100644 index 00000000..34807d6b --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/form-revisions/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: form_revision_list returns a form's revisions newest first + +The server SHALL register `form_revision_list` (replacing `form_revisions_list`), taking `cwd`, `formIdOrPath`, and the shared list arguments. It SHALL request revisions sorted by `-_vid` by default and select compact metadata (`_id`, `_vid`, `_vnote`, `_vuser`, `created`, `modified`) unless `select` is given, and SHALL return the shared list result. + +#### Scenario: Newest first + +- **WHEN** a form has 15 revisions and `form_revision_list` is called +- **THEN** the first item is the highest `_vid`, all 15 are returned, and `total` is 15 + +## REMOVED Requirements + +### Requirement: form_revisions_list returns revision summaries + +**Reason**: Renamed to `form_revision_list` for singular tool names, and it now pages and sorts. +**Migration**: Call `form_revision_list` with the same `formIdOrPath`. + +### Requirement: License gate prompts once for standard writes on unlicensed deployments + +**Reason**: The prompt ran out of band and its answer was persisted per deployment. +**Migration**: Pass `acceptNoHistory: true` after asking the user, as the `revisions-history-acceptance` capability describes. + +### Requirement: Per-form tracking gate prompts when revisions are off + +**Reason**: The prompt ran out of band (elicitation or a local browser page) in the middle of a write. +**Migration**: Pass `acceptNoHistory: true`, or set `form.revisions` to enable history. diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/form-update/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/form-update/spec.md new file mode 100644 index 00000000..b25c6d03 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/form-update/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: form_update saves a form or its draft from the body it is given + +`form_update` SHALL take `cwd`, `formId` (a 24-character ObjectId), `form`, a required `note`, an optional `draft: boolean`, and an optional `acceptNoHistory: boolean`. Without `draft` it SHALL PUT the form. With `draft: true` it SHALL save the draft from the allowlisted draft fields of `form` (`components`, `settings`, `tags`, `properties`, `controller`, `esign`, `display`). It SHALL ignore the server-owned fields `form_get` returns (`_id`, `_vid`, `_rid`, `revisionId`, `created`, `modified`, `owner`, `project`, `machineName`, and the like), and SHALL ignore a caller-editable non-draft field (`title`, `name`, `path`, `type`, `action`, `access`, `submissionAccess`, `fieldMatchAccess`, `revisions`, `submissionRevisions`, `pdfComponents`, `translationsUrl`) whose value equals the stored one — the stored draft's, or the live form's where a draft exists. A caller-editable non-draft field whose value differs from both SHALL be refused with code `INVALID_ARGUMENT`, naming the field and saying to save it with `form_update` without `draft`, and nothing SHALL be written. It SHALL NOT accept `publish`, `revert` or `version`; publishing and reverting are `form_publish` and `form_revert`. + +#### Scenario: Saving a draft from form_get's output + +- **WHEN** `form_update` is called with `draft: true` and a `form` returned by `form_get` (carrying `_id`, `title`, `path`, `created`) +- **THEN** the draft is saved with that form's allowlisted fields +- **AND** no error is returned for the other fields + +#### Scenario: A changed non-draft field in a draft body is refused + +- **WHEN** `form_update` is called with `draft: true` and a `form` whose `title` or `path` differs from the stored form's +- **THEN** the tool returns `isError: true` with `_meta["io.form/error"].code` `INVALID_ARGUMENT`, naming the field and `form_update` without `draft` +- **AND** nothing is written + +#### Scenario: Publish and revert are separate tools + +- **WHEN** `form_update` is listed +- **THEN** its input schema has no `publish`, `revert` or `version` argument + +## REMOVED Requirements + +### Requirement: form_update exposes draft, publish, revert flags + +**Reason**: Publish and revert take no form body and their own arguments, so folding them behind flags forced a required `form` they ignore. +**Migration**: Keep `draft: true` on `form_update`; call `form_publish` (`formId`, `note`) to publish and `form_revert` (`formId`, `version`, `note`) to revert. + +### Requirement: form_update applies the per-form tracking gate on standard PUTs + +**Reason**: The gate prompted the user out of band (elicitation or a local browser page) in the middle of a tool call. +**Migration**: Pass `acceptNoHistory: true`, or set `form.revisions` to enable history, as the `revisions-history-acceptance` capability describes. diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/project-directory-resolution/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/project-directory-resolution/spec.md new file mode 100644 index 00000000..8ff549dd --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/project-directory-resolution/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: The directory a call resolves against comes from the client when the caller gives none + +Every tool that resolves a project SHALL choose the directory to resolve against in this order, and SHALL use the first that yields one: + +1. the `cwd` argument, when the caller passes it; +2. the client's workspace roots, read with `roots/list` when the client declared the `roots` capability at initialization: a single root is used as is; with several roots, the roots that resolve a project record (a committed `formio.json` found by the upward walk, or a directory mapping — a record that cannot be read or is malformed still counts) decide: when they all resolve the same record, the first of them in the client's order is used, and when none does, this source yields no directory; +3. the `CLAUDE_PROJECT_DIR` environment variable, when set to an absolute path; +4. the server process's own working directory. + +Roots SHALL be read as `file://` URIs and converted to normalized paths, and two roots naming the same directory SHALL count as one; a root that is not a `file://` URI SHALL be ignored. The server SHALL cache the roots it read and SHALL read them again after the client sends `notifications/roots/list_changed`. A `roots/list` request that fails or does not answer within 2 seconds SHALL leave the last roots read successfully in use, and SHALL be treated as no roots only when no read has succeeded, in which case resolution SHALL continue with the next source. + +When the client reports several roots, the caller passes no `cwd`, and the roots resolve different project records, the tool SHALL refuse with code `INVALID_ARGUMENT` and a message listing the roots and asking for `cwd`. Two records are different when they are different committed files or different directory mappings, even where they name the same Project URL. + +The `cwd` argument SHALL stay optional on every tool. Its description SHALL say it defaults to the client's workspace root and when to pass it. + +#### Scenario: A client with one root + +- **WHEN** the client declares `roots` and reports `file:///work/app`, and a tool is called with no `cwd` +- **THEN** the project is resolved for `/work/app` + +#### Scenario: Several roots, one with a project + +- **WHEN** the client reports `file:///work/app` and `file:///work/lib`, only `/work/app` has a `formio.json`, and a tool is called with no `cwd` +- **THEN** the project is resolved for `/work/app` + +#### Scenario: Several roots, one repository + +- **WHEN** the client reports `file:///work/repo/a` and `file:///work/repo/b`, both walk up to `/work/repo/formio.json`, and a tool is called with no `cwd` +- **THEN** the project is resolved for `/work/repo/a` + +#### Scenario: Several roots, different records + +- **WHEN** the client reports two roots that resolve different project records, and a tool is called with no `cwd` +- **THEN** the tool returns code `INVALID_ARGUMENT` listing both roots and asking for `cwd` + +#### Scenario: Several roots, no records + +- **WHEN** the client reports two roots, neither resolves a project record, `FORMIO_PROJECT_URL` names a project, and a tool is called with no `cwd` +- **THEN** resolution continues with `CLAUDE_PROJECT_DIR`, then the server's working directory, and the environment's project is used + +#### Scenario: An explicit cwd wins + +- **WHEN** the client reports `file:///work/app` and a tool is called with `cwd: "/work/other"` +- **THEN** the project is resolved for `/work/other` + +#### Scenario: No roots capability + +- **WHEN** the client did not declare `roots`, `CLAUDE_PROJECT_DIR` is `/work/app`, and a tool is called with no `cwd` +- **THEN** the project is resolved for `/work/app` +- **AND** no `roots/list` request is sent + +#### Scenario: The roots request does not answer + +- **WHEN** the client declares `roots` but does not answer `roots/list` within 2 seconds +- **THEN** resolution continues with `CLAUDE_PROJECT_DIR`, then the server's working directory, unless an earlier `roots/list` succeeded, whose roots stay in use + +#### Scenario: Roots change + +- **WHEN** the client sends `notifications/roots/list_changed` after reporting a new root +- **THEN** the next call resolves against the new root + +### Requirement: Project reports say where the directory came from + +`project_get` and `server_status` SHALL report `cwdSource`, one of `argument`, `client-root`, `claude-project-dir`, `server`, naming which source in the order above supplied the directory. When `cwdSource` is `server` or `claude-project-dir`, neither of which is a directory the caller or the client named, the report SHALL say which directory it is — the server process's own working directory, or the one `CLAUDE_PROJECT_DIR` names — and that the agent should pass `cwd` on later calls: in its notes when a project resolves, and in its message, with no `remedy` keyed to that directory, when nothing is configured. A project from the environment whose deployment also resolves is the same answer for every directory, so it SHALL carry no such note. No other source SHALL produce that note. + +#### Scenario: Resolved from the client's root + +- **WHEN** `project_get` is called with no `cwd` and the client reports one root +- **THEN** the report has `cwdSource: "client-root"` and no note about the server's working directory + +#### Scenario: Fell back to the server's directory + +- **WHEN** `project_get` is called with no `cwd`, the client declares no `roots`, `CLAUDE_PROJECT_DIR` is unset, and a directory mapping for the server's own working directory supplies the project +- **THEN** the report has `cwdSource: "server"` and a note telling the agent to pass `cwd` diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/project-map-routing/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/project-map-routing/spec.md new file mode 100644 index 00000000..851c2790 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/project-map-routing/spec.md @@ -0,0 +1,49 @@ +## MODIFIED Requirements + +### Requirement: Unresolvable projects fail with an actionable error naming project_set + +When no project can be resolved, every project-scoped tool SHALL fail with an error that names the remedies available to the caller: calling `project_set` with the caller's `cwd`, running the equivalent command, and committing a `formio.json`. The error SHALL echo the `cwd` that was searched when one was supplied, and SHALL say that the upward walk for a committed file found none. It SHALL describe what a Project URL is, with an example per deployment kind, because that is the value it is asking for. + +It SHALL NOT recite the base-URL guidance. The base URL is derived from whichever project URL the user supplies, so guidance about it cannot be acted on before that answer exists, and carrying it here made the message ask for two values when one is needed. It SHALL NOT name a suggested project, because no variable offers one. + +A committed file that is present but unusable SHALL fail with a DISTINCT error naming that file's path and the offending key, and SHALL NOT report the directory as unconfigured. + +A project URL that resolves while its base URL does not SHALL fail at the point authentication needs the value. That error SHALL name `project_set` and its `baseUrl` argument, the `baseUrl` key of a committed file, SHALL echo the resolved project URL, and SHALL explain that a path-less project URL on a customer domain names its deployment nowhere. It SHALL NOT report the project as unconfigured. Because the base URL is read only by authentication, that failure SHALL be scoped to callers that authenticate with a JWT: a deployment configured with `FORMIO_API_KEY` SHALL complete its calls normally in this shape. + +#### Scenario: The unset-project error asks for the project alone + +- **WHEN** nothing resolves a project for `cwd` of `/work/app` +- **THEN** the tool fails with an error containing `project_set`, `formio.json`, and `/work/app` +- **AND** it describes what a Project URL is +- **AND** it does not ask for a base URL +- **AND** the server remains connected + +#### Scenario: No suggested project appears in the error + +- **WHEN** nothing resolves a project and `FORMIO_DEFAULT_PROJECT_URL` is set in the environment +- **THEN** the error names no suggested project +- **AND** the message is identical to the one raised when that variable is unset + +#### Scenario: A broken committed file is distinguishable from an unmapped directory + +- **WHEN** a `formio.json` is found for `cwd` but cannot be parsed +- **THEN** the error names that file's path +- **AND** it does not claim the directory is unconfigured + +#### Scenario: A JWT call against a project with no derivable base URL is actionable + +- **WHEN** the resolved project URL is `https://myproject.mysite.com`, no source supplies a base URL, and no `FORMIO_API_KEY` is set +- **THEN** the tool fails with an error naming `project_set`, `baseUrl`, and the `formio.json` key +- **AND** the error echoes `https://myproject.mysite.com` +- **AND** no portal-login browser window is opened and no request is made to `https://api.form.io` + +#### Scenario: An API-key deployment is unaffected by an unresolved base URL + +- **WHEN** `FORMIO_API_KEY` is set and the resolved project URL is `https://myproject.mysite.com` with no base URL from any source +- **THEN** `form_list` proceeds against the project URL and succeeds + +#### Scenario: server_status needs no project + +- **WHEN** nothing configures a project +- **AND** `server_status` is called +- **THEN** it succeeds, reporting `status: "not-configured"` diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/revisions-history-acceptance/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/revisions-history-acceptance/spec.md new file mode 100644 index 00000000..5a57506c --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/revisions-history-acceptance/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Saving without revision history is an explicit argument, not a prompt + +`form_create` and `form_update` SHALL accept an optional `acceptNoHistory: boolean`. A write that would save a form without revision history — on a deployment without the revisions licence, to a form whose stored `revisions` setting is off when the caller does not enable it, or, on a licensed deployment, a `form_create` or `form_update` whose body sets `revisions` to `""` (turning history off, whatever the stored setting) — SHALL proceed only when `acceptNoHistory` is `true`. Otherwise the tool SHALL make no write and return `isError: true` with code `HISTORY_NOT_ACCEPTED` and a message stating why the save would have no history, telling the agent to ask the user and retry with `acceptNoHistory: true`, or (for a licensed deployment) with `form.revisions` set to `"original"` or `"current"` to enable history. No tool SHALL open an elicitation request or a local browser page to ask this, and no answer SHALL be persisted between calls. + +#### Scenario: Unlicensed deployment without acceptance + +- **WHEN** `form_create` is called on a deployment without the revisions licence and no `acceptNoHistory` +- **THEN** no POST is sent +- **AND** the result has code `HISTORY_NOT_ACCEPTED` and a message telling the agent to ask the user + +#### Scenario: Unlicensed deployment with acceptance + +- **WHEN** `form_create` is called on that deployment with `acceptNoHistory: true` +- **THEN** the form is created without a `revisions` setting + +#### Scenario: A licensed form with revisions off + +- **WHEN** `form_update` saves a form whose stored `revisions` is empty, without `acceptNoHistory` and without `form.revisions` +- **THEN** no PUT is sent and the result has code `HISTORY_NOT_ACCEPTED` +- **AND WHEN** the call is retried with `form.revisions: "original"` +- **THEN** the PUT enables revisions and saves + +#### Scenario: Explicitly disabling history + +- **WHEN** `form_create` or `form_update` is called on a licensed deployment with `form.revisions: ""` and without `acceptNoHistory` +- **THEN** no write is sent and the result has code `HISTORY_NOT_ACCEPTED` +- **AND** the message tells the agent to ask the user and retry with `acceptNoHistory: true`, or to omit `revisions` or set it to `"original"` or `"current"` + +#### Scenario: Nothing prompts + +- **WHEN** any form write runs in a client that supports elicitation +- **THEN** no elicitation request is sent and no local page is opened diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/role-create/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/role-create/spec.md new file mode 100644 index 00000000..421d924b --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/role-create/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: role_create takes a nested role object + +`role_create` SHALL take `cwd` and a `role` object (`title` required; `description`, `default`, `admin` optional), matching `role_update`, and SHALL POST it to `{projectUrl}/role`, returning the created role. + +#### Scenario: Creating a role + +- **WHEN** `role_create` is called with `role: { title: "Manager" }` +- **THEN** a POST to `/role` carries `{ title: "Manager" }` and the created role is returned + +## REMOVED Requirements + +### Requirement: role_create creates a new role + +**Reason**: The flat `title` / `description` / `default` / `admin` arguments differed from every other create and update tool. +**Migration**: Pass the same fields inside `role`. diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/role-list/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/role-list/spec.md new file mode 100644 index 00000000..52d1f925 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/role-list/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: role_list pages the project's roles + +`role_list` SHALL take `cwd` and the shared list arguments and SHALL return the shared list result of roles. + +#### Scenario: All roles are reachable + +- **WHEN** a project has 12 roles and `role_list` is called with no arguments +- **THEN** all 12 are returned with `total: 12` + +## REMOVED Requirements + +### Requirement: role_list retrieves all project roles + +**Reason**: It sent no `limit`, so Form.io's default of 10 truncated the result; replaced by the shared list contract. +**Migration**: Read `total` and `hasMore` instead of `count`. diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/server-config/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/server-config/spec.md new file mode 100644 index 00000000..f139f482 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/server-config/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Boolean environment variables share one parser + +Every boolean environment variable the server reads (`FORMIO_INSECURE_TLS`, `FORMIO_FORCE_BROWSER`) SHALL be read by one parser: after trimming, `true` and `1` (case-insensitive) are true and every other value, including unset, is false. + +#### Scenario: Same spelling, same meaning + +- **WHEN** `FORMIO_FORCE_BROWSER` is `TRUE` and `FORMIO_INSECURE_TLS` is ` 1 ` +- **THEN** both are read as true + +### Requirement: The package publishes its binary and no library entry + +`@formio/mcp`'s `package.json` SHALL declare `exports` exposing only `./package.json`, so that no module under `dist/` is importable by consumers; the package's interface is its `formio-mcp` binary. + +#### Scenario: No deep import + +- **WHEN** a consumer imports `@formio/mcp/dist/server.js` +- **THEN** module resolution fails with a package-exports error diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/server-status/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/server-status/spec.md new file mode 100644 index 00000000..36913c6f --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/server-status/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: server_status reports the server and the caller's project resolution + +The server SHALL register a `server_status` tool, replacing `hello`, taking an optional `cwd`. It SHALL return the server's name and version and, for that `cwd`, the same resolution `project_get` reports (`status`, `projectUrl`, `baseUrl`, their sources). It SHALL make no Form.io request and SHALL succeed when no project is configured, reporting `status: "not-configured"`. + +#### Scenario: No project configured + +- **WHEN** nothing configures a project and `server_status` is called +- **THEN** it succeeds with the server version and `status: "not-configured"` + +#### Scenario: A configured directory + +- **WHEN** a directory maps `https://examples.form.io` and `server_status` is called with that `cwd` +- **THEN** it reports the server version, `status: "ok"`, and both URLs with their sources +- **AND** no request is made to Form.io + +#### Scenario: hello is gone + +- **WHEN** the tool list is read +- **THEN** it contains `server_status` and no `hello` diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/tool-description-budget/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/tool-description-budget/spec.md new file mode 100644 index 00000000..820ba049 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/tool-description-budget/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: The tool list fits a measured budget + +The serialized `tools/list` result SHALL NOT exceed 42,000 characters, and a test SHALL measure it. The `cwd` argument description SHALL NOT exceed 100 characters, and SHALL state that the argument is optional and that it defaults to the client's workspace root; when to pass it, and the order a directory is chosen in, SHALL be stated in the server instructions, which every client receives once. A tool description SHALL carry what a caller acts on — when to use the tool, what it requires, what it returns, and the refusals it can raise — and SHALL NOT carry change history or design rationale, which belong in the README or code comments. + +#### Scenario: The budget is enforced + +- **WHEN** the test suite lists every registered tool +- **THEN** the serialized result is at most 42,000 characters + +#### Scenario: The cwd description is short + +- **WHEN** any tool's `cwd` argument is inspected +- **THEN** its description is at most 100 characters, says the argument is optional, and says it defaults to the client's workspace root + +#### Scenario: The full cwd rules stay in the server instructions + +- **WHEN** the server instructions are read +- **THEN** they state the order a directory is chosen in and that a caller passes `cwd` when `project_get` reports `cwdSource` `"server"` or `"claude-project-dir"`, or to target another directory + +### Requirement: Descriptions sit where a caller acts on them + +An output schema SHALL describe only the fields a caller branches on — `project_set`'s `ok`, `status`, `total`, `hasMore`, `remedy` and the `remedy.supply` inside it — and SHALL leave every other field typed and undescribed. `ok` SHALL say that false means the record was written but the directory still needs attention, that `message` names what to do, and not to retry the call. Every input argument this server defines (`cwd`, `note`, `draft`, `acceptNoHistory`, `formId`, `formIdOrPath`, `version`, `limit`, `skip`, `sort`, `select`, `tags`, `projectUrl`, `baseUrl`) SHALL keep its description. The fields nested inside a form, action or role body are Form.io's own and SHALL carry no description, their meaning being left to the `formio-schema` and `formio-actions` skills — except a body field this server gives a meaning of its own, which is `form.revisions` on `form_create` and `form_update`, because `acceptNoHistory` depends on it. + +#### Scenario: Output fields are described only where a caller branches + +- **WHEN** every tool's output schema is walked +- **THEN** the only properties carrying a description are `ok`, `status`, `total`, `hasMore`, `remedy` and `remedy.supply` + +#### Scenario: Server-defined arguments keep their descriptions + +- **WHEN** any tool declares one of the server-defined arguments +- **THEN** that argument carries a description + +#### Scenario: Form.io body fields are left to the skills + +- **WHEN** the fields nested inside an input argument are walked +- **THEN** none carries a description except `form.revisions` on `form_create` and `form_update` + +### Requirement: Form.io document schemas declare only the fields a caller acts on + +Each Form.io document's output schema SHALL stay open, so a field it does not declare passes through untouched, and SHALL declare only the fields a caller acts on, none of them required: a form `_id`, `title`, `name`, `path`, `type`, `display`, `components`, `revisions`, and `access` / `submissionAccess` (which the auth skills read before editing them); an action `_id`, `name`, `title`, `form`, `handler`, `method`, `settings`, `condition`, `priority`; a role `_id`, `title`, `admin`, `default`; a revision summary `_id`, `_vid`, `_vnote`, `_vuser`, `created`; an action type `name`, `title`, plus `settingsForm` on `action_type_get`; a template `roles`, `resources`, `forms`, `actions`. Every annotation `title` SHALL be at most four words. + +#### Scenario: A document declares its acted-on fields and passes the rest through + +- **WHEN** a Form.io document schema is inspected +- **THEN** it declares exactly its listed fields, requires none of them, and leaves additional properties open + +#### Scenario: Annotation titles are short + +- **WHEN** any tool's annotations are inspected +- **THEN** its `title` is at most four words diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/tool-error-contract/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/tool-error-contract/spec.md new file mode 100644 index 00000000..506669bc --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/tool-error-contract/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Tool errors carry a structured code alongside the message + +Every error a tool handler returns SHALL be a result with `isError: true`, a human-readable text message whose first characters are `[CODE] ` (the code in brackets and one space, ahead of any notes and the message), and `_meta["io.form/error"]` of `{ code, status?, body? }`. An error result SHALL carry no `structuredContent`, because MCP clients validate `structuredContent` against the tool's success `outputSchema` even when `isError` is set. `code` SHALL be one of: `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`, `INTERNAL`. `status` SHALL be the Form.io HTTP status when the error came from a response. `body` SHALL be Form.io's response body, as text truncated to 2,000 characters, when one was returned. The message SHALL include Form.io's error message when the body carries one. Adding a code is a minor change; renaming or removing one is breaking. + +The mapping from Form.io responses SHALL be: 401 after re-authentication → `AUTH_REQUIRED`; 403 → `FORBIDDEN`; 404, and 400 "Invalid alias" → `NOT_FOUND`; other 400 and 422 → `VALIDATION_FAILED`; 3xx → `REDIRECTED`; 5xx → `UPSTREAM_ERROR`; a request that received no response → `NETWORK_ERROR`, with the underlying cause (e.g. `ECONNREFUSED`, `ENOTFOUND`, a certificate error) in the message. + +#### Scenario: A Form.io validation error keeps its message + +- **WHEN** `form_create` is sent a form whose path is taken and Form.io answers 400 with a JSON body naming the path +- **THEN** the result has `isError: true`, `_meta["io.form/error"]` with `code` `VALIDATION_FAILED`, `status: 400`, and `body` holding Form.io's response +- **AND** the text starts with `[VALIDATION_FAILED] ` and the result has no `structuredContent` +- **AND** the message includes Form.io's error text + +#### Scenario: An unreachable deployment names the cause + +- **WHEN** the deployment refuses the connection +- **THEN** the result has `_meta["io.form/error"].code` `NETWORK_ERROR` and the message names `ECONNREFUSED` and the URL + +#### Scenario: Resolution errors carry codes + +- **WHEN** a project-scoped tool is called for a directory with no project +- **THEN** the result has `_meta["io.form/error"].code` `NOT_CONFIGURED` and the text is `[NOT_CONFIGURED] ` followed by the existing remedy message + +#### Scenario: An error result passes through a client that validates output + +- **WHEN** a client that validates `structuredContent` against each tool's `outputSchema` calls `form_create` or `role_list` and Form.io answers 404 +- **THEN** the call returns without a validation error, with `isError: true` and `_meta["io.form/error"].code` `NOT_FOUND` diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/tool-list-contract/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/tool-list-contract/spec.md new file mode 100644 index 00000000..46e928db --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/tool-list-contract/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Every paged list tool shares one contract + +`form_list`, `role_list`, `action_list` and `form_revision_list` SHALL accept `limit` (positive integer, default 100), `skip` (non-negative integer, default 0), `sort` (Form.io sort string, e.g. `-modified`) and `select` (comma-separated fields), and SHALL send `limit` and `skip` on every request so Form.io's own default page size never applies. Each SHALL return its items plus `total` — the item count Form.io reports in the `Content-Range` header — and `hasMore`, true when `skip + items.length < total`. When Form.io reports no total, the result SHALL omit `total` rather than estimate it, and `hasMore` SHALL be true when the page came back full (`items.length` equal to `limit`); the output schema SHALL declare `total` optional, with a description saying it is absent when Form.io does not report one. A `skip` at or past `total` SHALL return no items with the reported `total`, not an error. The result SHALL NOT carry a `count` field. + +#### Scenario: More roles than the default page + +- **WHEN** a project has 12 roles and `role_list` is called with no arguments +- **THEN** it returns 12 roles, `total: 12`, `hasMore: false` + +#### Scenario: Paging through forms + +- **WHEN** a project has 150 forms and `form_list` is called with no arguments +- **THEN** it returns 100 forms, `total: 150`, `hasMore: true` +- **AND WHEN** it is called with `skip: 100` +- **THEN** it returns 50 forms and `hasMore: false` + +#### Scenario: Skipping past the end + +- **WHEN** `action_list` is called with `skip: 500` on a form with 3 actions +- **THEN** it returns no actions, `total: 3`, `hasMore: false`, and no error + +#### Scenario: Form.io reports no total + +- **WHEN** a list tool is called with `limit: 2` and Form.io answers with 2 items and no `Content-Range` total +- **THEN** the result carries the 2 items and `hasMore: true`, and no `total` + +### Requirement: The action-type catalog is returned whole + +`action_type_list` SHALL return every action type the form supports, with `total` equal to the number returned and `hasMore: false`, because Form.io does not page that route. It SHALL accept no paging arguments. + +#### Scenario: The full catalog + +- **WHEN** `action_type_list` is called for a form +- **THEN** every action type Form.io returns is in the result and `hasMore` is false diff --git a/openspec/changes/freeze-1-0-tool-surface/specs/tool-output-schemas/spec.md b/openspec/changes/freeze-1-0-tool-surface/specs/tool-output-schemas/spec.md new file mode 100644 index 00000000..e3718e81 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/specs/tool-output-schemas/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Output schemas never reject a valid Form.io response + +Every tool's `outputSchema` SHALL allow properties beyond the ones it documents at every level, including the top level, and every documented property of a Form.io document SHALL accept the types Form.io stores for it, including `null` where Form.io stores `null`. A response from Form.io that a tool returns SHALL never fail output validation in the server or in a client that validates against the published schema. + +#### Scenario: A form with fields the schema does not list + +- **WHEN** `form_get` returns a form carrying `_vid`, `pdfComponents`, `controller` and `esign` +- **THEN** the result validates against `form_get`'s published output schema +- **AND** those fields are present in `structuredContent` + +#### Scenario: Null settings + +- **WHEN** Form.io returns a form whose `settings` is `null` +- **THEN** `form_get` succeeds and returns it + +#### Scenario: Every published schema is open + +- **WHEN** the tool list is read +- **THEN** no tool's `outputSchema` sets `additionalProperties: false` at any level diff --git a/openspec/changes/freeze-1-0-tool-surface/tasks.md b/openspec/changes/freeze-1-0-tool-surface/tasks.md new file mode 100644 index 00000000..63ae5dc3 --- /dev/null +++ b/openspec/changes/freeze-1-0-tool-surface/tasks.md @@ -0,0 +1,206 @@ +## 1. Error contract + + +### Red + +- [x] 1.1 Write failing tests for `FormioApiError` / `FormioNetworkError` thrown by `formioFetch`: status, URL, body text truncated to 2,000 characters; Form.io's JSON `message` included in the prose +- [x] 1.2 Write failing tests for `toMcpError`: each known error class maps to its `code` (`NOT_CONFIGURED`, `BASE_URL_UNRESOLVED`, `CONFIG_UNREADABLE`, `INVALID_ARGUMENT`, `AUTH_REQUIRED`, `FORBIDDEN`, `NOT_FOUND`, `VALIDATION_FAILED`, `REDIRECTED`, `UPSTREAM_ERROR`, `NETWORK_ERROR`, unknown → `INTERNAL`) with `status` / `body` where present +- [x] 1.3 Write failing tool-level tests: a Form.io 400 on `form_create` yields `VALIDATION_FAILED` with body; `ECONNREFUSED` yields `NETWORK_ERROR` naming the cause and URL; an unconfigured directory yields `NOT_CONFIGURED`; an error result passes through an output-validating client + +### Green + +- [x] 1.4 Implement the typed errors in `formio-client.ts` and give the resolution error classes a `code` +- [x] 1.5 Implement the mapper in `mcp-responses.ts` (`_meta["io.form/error"]: { code, status?, body? }`, text led by `[CODE] `, no `structuredContent`) + +### Refactor + +- [x] 1.6 Review implementation and refactor as needed + +## 2. Open output schemas + + +### Red + +- [x] 2.1 Write a failing test that lists every tool and asserts no `outputSchema` contains `additionalProperties: false` at any level +- [x] 2.2 Write failing tests replaying recorded Form.io documents (form with `_vid`, `pdfComponents`, `controller`, `esign`, `settings: null`; revision with `_rid`, `_vnote`, `_vuser`; action with `condition: null`) through `form_get`, `form_revision_get`, `form_update`, `action_get` via a validating client + +### Green + +- [x] 2.3 Pass each `outputSchema` as `z.looseObject(...)` and let Mixed fields accept `null` in `output-schemas.ts` + +### Refactor + +- [x] 2.4 Review implementation and refactor as needed + +## 3. Renames and server_status + + +### Red + +- [x] 3.1 Write failing tests: the tool list contains `action_type_list`, `form_revision_list`, `server_status`, and not `action_types_list`, `form_revisions_list`, `hello` +- [x] 3.2 Write failing `server_status` tests: unconfigured → version + `status: "not-configured"` with no Form.io request; configured → version, status, both URLs and sources + +### Green + +- [x] 3.3 Rename the two list tools and their files/tests; replace `hello` with `server_status` built on `reportProject` +- [x] 3.4 Update `capability-quality`, `server-empty-env`, `project-resolution-errors` and `mcpb-build` tests and `scripts/build-mcpb.ts`'s smoke call + +### Refactor + +- [x] 3.5 Review implementation and refactor as needed + +## 4. List contract + + +### Red + +- [x] 4.1 Write failing tests for `formioFetch`'s `withMeta` option: parses `Content-Range` `0-9/42` and `*/0`; a 416 with `*/3` returns no items and `total: 3` +- [x] 4.2 Write failing tool tests for `form_list`, `role_list`, `action_list`, `form_revision_list`: default `limit=100&skip=0` always sent; `total` and `hasMore` returned; no `count`; `sort` / `select` forwarded +- [x] 4.3 Write failing tests: `form_list` `tags: ["a","b"]` sends `tags__all=a,b`; `form_revision_list` defaults `sort=-_vid` and compact `select`; `action_type_list` returns the whole catalog with `hasMore: false` + +### Green + +- [x] 4.4 Implement `withMeta` and the 416 handling in `formio-client.ts` +- [x] 4.5 Implement the shared list arguments/result (one helper) and apply it to the four paged tools and `action_type_list`; update output schemas + +### Refactor + +- [x] 4.6 Review implementation and refactor as needed + +## 5. form_publish, form_revert, form_update draft + + +### Red + +- [x] 5.1 Write failing tests for `form_publish`: publishes the draft's allowlisted fields with the note; `NO_DRAFT` with no PUT when there is no draft; no `form` argument; `LICENSE_REQUIRED` when unlicensed +- [x] 5.2 Write failing tests for `form_revert`: overlays the revision's allowlisted fields with the note; `NOT_FOUND` for an unknown version; `version` checked by the path-argument rule +- [x] 5.3 Write failing tests for `form_update`: no `publish` / `revert` / `version` arguments; `draft: true` with `form_get`'s output saves only the allowlisted fields; a draft body with none of them is `INVALID_ARGUMENT` + +### Green + +- [x] 5.4 Register `form_publish` and `form_revert` over `revisions/flows.ts` +- [x] 5.5 Remove publish/revert from `form_update`; change draft mode to pick allowlisted fields + +### Refactor + +- [x] 5.6 Review implementation and refactor as needed + +## 6. acceptNoHistory + + +### Red + +- [x] 6.1 Write failing tests: unlicensed `form_create` / `form_update` without `acceptNoHistory` → `HISTORY_NOT_ACCEPTED`, no write; with it → write without `revisions` +- [x] 6.2 Write failing tests: licensed `form_update` of a form with `revisions` off → `HISTORY_NOT_ACCEPTED`; retry with `form.revisions: "original"` enables and saves; retry with `acceptNoHistory: true` saves without +- [x] 6.3 Write a failing test that no form write sends an elicitation request or opens a local page, and that `~/.formio/revisions-license-consent.json` is neither read nor written + +### Green + +- [x] 6.4 Replace the licence and per-form gates with the `acceptNoHistory` check; delete `revisions/browser-prompts.ts` and the consent persistence and elicitation code +- [x] 6.5 Remove the tests that covered the deleted prompt paths (`browser-prompts-launch`, the prompt cases in `revisions`, `local-server-edges`, `runnable-remedies`) + +### Refactor + +- [x] 6.6 Review implementation and refactor as needed + +## 7. Argument shapes: role_create, action formId + + +### Red + +- [x] 7.1 Write failing tests: `role_create` takes `role: { title, description?, default?, admin? }` and POSTs it; flat `title` is rejected +- [x] 7.2 Write failing tests: every action tool (and `form_update`, `form_publish`, `form_revert`) refuses a non-ObjectId `formId` with `INVALID_ARGUMENT` and a message pointing to `form_get` for the `_id`; `action_create` and `action_type_get` refuse a type missing from the catalog with `UNKNOWN_ACTION_TYPE` + +### Green + +- [x] 7.3 Change `role_create`'s input to the shared role schema used by `role_update` +- [x] 7.4 Give every action tool's `formId` (and `form_update`'s, `form_publish`'s, `form_revert`'s) the shared ObjectId argument with the remedy message, checked in the handler so the refusal carries its code; wire `UNKNOWN_ACTION_TYPE` + +### Refactor + +- [x] 7.5 Review implementation and refactor as needed + +## 8. Environment booleans and package exports + + +### Red + +- [x] 8.1 Write failing tests: `FORMIO_FORCE_BROWSER` and `FORMIO_INSECURE_TLS` read `true`, `TRUE`, ` 1 ` as true and `0`, `false`, `yes`, unset as false +- [x] 8.2 Write a failing test that `packages/mcp-server/package.json` declares `exports` exposing only `./package.json` and still declares the `formio-mcp` bin + +### Green + +- [x] 8.3 Implement `readBooleanEnv` in `config.ts` and use it for both variables +- [x] 8.4 Add `exports` to `package.json` + +### Refactor + +- [x] 8.5 Review implementation and refactor as needed + +## 9. Description budget + + +### Red + +- [x] 9.1 Write a failing test that the serialized `tools/list` is at most 50,000 characters and every `cwd` description is at most 200 characters and names the user's working directory + +### Green + +- [x] 9.2 Shorten the `cwd` description; move `project_set`'s rationale to the README keeping every rule it enforces; trim output-schema field descriptions to what a caller branches on +- [x] 9.3 Confirm the server instructions still carry the full `cwd` and resolution guidance (pinned by a test), and grep `plugin/skills/` for any sentence that relies on wording removed from a tool description. The eval harnesses are not run for this: the planner calls no MCP tool and the resources harnesses grade generated code, so neither exercises a tool description + +### Refactor + +- [x] 9.4 Review implementation and refactor as needed + +## 10. Skills and docs follow the surface + + +### Red + +- [x] 10.1 Write a failing skill-tests check that no markdown under `plugin/skills/`, nor the three READMEs, names `action_types_list`, `form_revisions_list`, the `hello` tool, `form_update`'s `publish` / `revert`, flat `role_create` fields, a list `count`, or the revisions consent prompt +- [x] 10.2 Write a failing check that every copy of the shared preflight paragraph lists `form_publish`, `form_revert`, `role_update`, `action_update` and `action_delete` among the writing tools + +### Green + +- [x] 10.3 Update the preflight paragraph in all 15 skills together (`shared-prose-stays-identical.test.ts`), `formio-actions` (`action_type_list`), `formio-api` (`project-form-revisions.md` rewritten for the new tools and `acceptNoHistory`; `project-roles.md`), `formio-auth`, planner and `formio-sdk` references naming `role_create` +- [x] 10.4 Update the root `README.md`, `packages/mcp-server/README.md` (tool table, CLI flags and exit codes as the 1.0 reference, `project_set` rationale moved in), `plugin/README.md` and `server.json` + +### Refactor + +- [x] 10.5 Review implementation and refactor as needed + +## 11. Release notes and Definition of Done + + +### Red + +- [x] 11.1 Confirm `pnpm check:releases` coverage (it already passed: #88's pending minor changeset releases both packages, so the major changeset is what sets the bump, not what satisfies the check) + +### Green + +- [x] 11.2 Add a `major` changeset for `@formio/mcp` and `@formio/ai` listing every rename, the new tools, the list and error contracts, `acceptNoHistory`, and the removed consent file +- [x] 11.3 Run `pnpm test`, `pnpm lint`, `pnpm format`, `pnpm check:releases` and `openspec validate --strict` + +### Refactor + +- [x] 11.4 Review implementation and refactor as needed + +## 12. Directory from client roots + + +### Red + +- [x] 12.1 Write failing tests for the directory-source order (argument; one client root; several roots with exactly one project record; ambiguous several roots → INVALID_ARGUMENT listing them; CLAUDE_PROJECT_DIR; process cwd), including no `roots/list` request when the client did not declare `roots`, a `roots/list` that does not answer within 2 seconds, non-`file://` roots ignored, and a re-read after `notifications/roots/list_changed` +- [x] 12.2 Write failing tests that `project_get` and `server_status` report `cwdSource`, and that only `server` adds the note telling the agent to pass `cwd` +- [x] 12.3 Write failing skill-tests checks that the shared preflight tells the agent to call `project_get` without `cwd` and to pass `cwd` afterwards only when `cwdSource` is `server` (or to target another directory), and that no skill still says to pass `cwd` on every call + +### Green + +- [x] 12.4 Implement the directory provider (roots via a lenient `roots/list` request, cache that keeps the last good list, list_changed, timeout, CLAUDE_PROJECT_DIR, process cwd) and route every project-resolving tool and `project_set` through it +- [x] 12.5 Add `cwdSource` to the project report and its output schema; update the `cwd` description (≤200 characters) and the server instructions; keep `tools/list` under the budget +- [x] 12.6 Update the shared preflight paragraph in every skill identically, the other skill passages that say to pass `cwd` on every call, and both READMEs + +### Refactor + +- [x] 12.7 Review implementation and refactor as needed diff --git a/packages/mcp-server/DOCKERHUB.md b/packages/mcp-server/DOCKERHUB.md index d57abca7..6d2416cb 100644 --- a/packages/mcp-server/DOCKERHUB.md +++ b/packages/mcp-server/DOCKERHUB.md @@ -71,7 +71,7 @@ A private hostname will not resolve inside the container — map it with `--add- ## Tools -`form_list`, `form_get`, `form_create`, `form_update`, `form_revisions_list`, `form_revision_get`, `role_list`, `role_create`, `role_update`, `action_list`, `action_get`, `action_create`, `action_update`, `action_delete`, `action_types_list`, `action_type_get`, `project_export`, `project_import`, and `hello` for a no-auth smoke test. +`form_list`, `form_get`, `form_create`, `form_update`, `form_publish`, `form_revert`, `form_revision_list`, `form_revision_get`, `role_list`, `role_create`, `role_update`, `action_list`, `action_get`, `action_create`, `action_update`, `action_delete`, `action_type_list`, `action_type_get`, `project_export`, `project_import`, `project_get`, `project_set`, and `server_status`, which reports the server version and project resolution without credentials. ## Links diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 0bff69f6..7e3e1b44 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -43,7 +43,7 @@ The same stdio entry works everywhere, but the file it goes in **and the key it Every tool that reaches Form.io needs a project. It can come from a committed `formio.json`, from a per-directory mapping written by the `project_set` tool, or from `FORMIO_PROJECT_URL` in the environment — in that order, narrowest scope first, so a mapping overrides the environment and a committed file overrides both. `FORMIO_BASE_URL` is optional and usually unnecessary: the base URL is derived from the project URL's shape — `https://api.form.io` for a project on a `form.io` host, the parent path for a sub-directory-routed one — and is asked for only when it cannot be derived. No plugin manifest prompts for either value; every client records them per directory with `project_set` or a committed `formio.json`, and the `.mcpb` desktop bundle is the one exception because a desktop host has no working directory to interview in. -The server starts without either one, so a client can connect and list the tools before anything is configured — the project URL is only demanded at the point a tool needs it, and `hello` works regardless. +The server starts without either one, so a client can connect and list the tools before anything is configured — the project URL is only demanded at the point a tool needs it, and `server_status` works regardless. ### Run in Docker @@ -82,7 +82,7 @@ Wired into a client — the same entry as [Connect a client](#connect-a-client), `FORMIO_API_KEY` is the recommended path — no browser, nothing interactive. Browser login also works if you publish the auth port; see [Headless environments](#headless-environments) for both. -Two container-specific notes regardless of auth mode. Prefer the `FORMIO_PROJECT_URL` environment variable over the `project_set` tool: `project_set` persists its per-directory mapping to `~/.formio/projects.json`, which lives inside the container and is discarded when it exits. And the `cwd` argument every tool takes refers to a path *inside* the container, not on your host. +Two container-specific notes regardless of auth mode. Prefer the `FORMIO_PROJECT_URL` environment variable over the `project_set` tool: `project_set` persists its per-directory mapping to `~/.formio/projects.json`, which lives inside the container and is discarded when it exits. And the `cwd` argument every tool takes refers to a path *inside* the container, not on your host — while a workspace root the client reports is a host path, so pass `cwd` explicitly. To reuse a token across container runs, mount the cache directory — it must be writable, since the server rewrites the file when a token is refreshed or cleared: @@ -161,11 +161,11 @@ The same run, step by step: ![Server card for formio-mcp showing Connected and the docker command it runs](https://raw.githubusercontent.com/formio/ai/main/packages/mcp-server/docs/images/inspector-4-connected.jpg) -**5. Open the Tools tab** for the tools this server exposes. Every server lists all 21 — including `project_set` and `project_get`, which are registered for every client. +**5. Open the Tools tab** for the tools this server exposes. Every server lists all 23 — including `project_set` and `project_get`, which are registered for every client. -![Tools tab listing hello, form_create, form_get, form_list and the rest](https://raw.githubusercontent.com/formio/ai/main/packages/mcp-server/docs/images/inspector-5-tools.jpg) +![Tools tab listing the server's Form.io tools](https://raw.githubusercontent.com/formio/ai/main/packages/mcp-server/docs/images/inspector-5-tools.jpg) -**6. Pick a tool, fill in its arguments, and press "Execute Tool".** The result appears in the middle pane and the JSON-RPC exchange in the right-hand Protocol panel. `hello` is the one tool that touches no credentials, so it isolates transport problems from auth problems; `form_list` below is a real call against a project. +**6. Pick a tool, fill in its arguments, and press "Execute Tool".** The result appears in the middle pane and the JSON-RPC exchange in the right-hand Protocol panel. `server_status` touches no credentials and makes no Form.io request, so it isolates transport problems from auth problems; `form_list` below is a real call against a project. ![form_list results showing form definitions returned from a Form.io project](https://raw.githubusercontent.com/formio/ai/main/packages/mcp-server/docs/images/inspector-6-tool-result.jpg) @@ -175,37 +175,39 @@ Importing writes the server into the inspector's own catalog at `~/.mcp-inspecto ## 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-form` 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-form`, then update. | -| `form_revisions_list` | List a form's saved revisions. | -| `form_revision_get` | Fetch one revision of a form by revision id. | +| `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 @@ -217,11 +219,32 @@ The bundled `@formio/mcp` server exposes these tools. Skills prefer these over r | `project_get` | Report which project a directory resolves to, which deployment hosts it, and which layer supplied each. The preflight to run before the first call that reads or writes — it answers from inside the server, with the same resolver every other tool uses, so no shell command is needed to ask it. Returns a `status` of `ok`, `not-configured`, or `base-url-unresolved`. | | `project_set` | Persist a Project URL for a directory, in `~/.formio/projects.json`. To record the target with the code instead, write a committed `formio.json` in the application's own folder — the server reads that file and never writes it. One server can serve several workspaces. Registered in every client. A mapping written here overrides `FORMIO_PROJECT_URL` in the server environment, which is the weakest source. | +#### Why `project_set` works this way + +The tool's description carries only the rules a caller acts on. The reasons behind them: + +- **The call is the persistence.** An agent that answers "switch to project X" in text has changed nothing; only the tool call writes the mapping. +- **The mapping is keyed by the directory the call resolves to.** That is `cwd` when it is passed, and otherwise the same directory every other tool resolves (see `cwd` under the conventions below). Only the last fallback, the server process's own working directory, is fixed when the client spawns the server and often not where the user is working, so a write keyed there says so. +- **No restart, but not always in effect.** Every tool resolves its project on each call, so a new mapping is read by the next call. It governs only where the mapping is the record that wins: under a committed `formio.json` it is the fallback for when that file goes away. That is why the result reports the pair that actually resolves (`projectUrl`, `ok`) rather than the one just recorded. +- **The Base URL is derived, not asked for.** It builds the portal-login URL and keys the cached token, so a wrong one fails at login rather than on a request, and a guessed one sends the login to a deployment the user does not use. It can be derived for a `form.io` host (`https://api.form.io`, the Base URL every hosted project shares) and for a sub-directory project (the parent path). A path-less project URL on a customer domain is the exception: its deployment is a sibling sub-domain, and nothing in the project URL names it. +- **A deployment is bound to its project.** The Base URL is stored per directory beside the project recorded with it, so each directory can target a different deployment and no deployment answers for another project. A call that re-points a directory to a different project therefore keeps no Base URL: the old value belonged to the project being replaced. +- **The mapping is machine-local.** It is keyed by absolute path, so it does not survive a clone. A committed `formio.json` is versioned, visible in a diff and shared with everyone who clones the repository, which is why it is preferred when the target belongs to the application. +- **Unknown arguments are refused.** The input schema is strict: an argument the tool does not take (such as the removed `scope`) is rejected rather than dropped, so a call written against older documentation cannot report a write that never happened. + ### Diagnostic | 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. --- @@ -287,7 +310,28 @@ The probe runs lazily — only when the local auth page is actually served. | `FORMIO_INSECURE_TLS` | no | `undefined` | Set to `1` to skip TLS verification. Local development only — never against production. | | | | `FORMIO_FORCE_BROWSER` | no | `0` | Set to `1` to attempt the browser login even where the server detects no browser (CI, a container, SSH with no display). | | | -\* Not at startup — the server starts, lists every tool, and answers `hello` without it; only the tools that read or write Form.io data error, naming `project_set` and this variable. The alternative is the `project_set` tool, which maps a working directory to a project in `~/.formio/projects.json`. Resolution runs by scope, narrowest first: a committed `formio.json` found by walking up from the caller's `cwd`, then the mapping for that `cwd`, then `FORMIO_PROJECT_URL` in the environment as the weakest source, then the error. Map a directory before any client connects with `npx -y @formio/mcp@0.14.1 project set --project-url --cwd ` — the deployment is derived from the project URL wherever it can be, so add `--base-url ` only when the server says it cannot be determined. Add `--force` beside both URLs to record a pair the domain rules refuse — the one shape they cannot tell from a mistake is an internal deployment served from a `*.form.io` domain; it takes both URLs in the same call, is honoured on every later read, and is a shell-only flag the `project_set` tool does not have. `project set --reset --cwd ` clears a directory's entry, which is how a forced pair is un-forced: a write that leaves both halves untouched keeps the override, so there is nothing for an unforced re-record to change. `project get --cwd ` prints what resolves and which source won. It exits `0` when it resolved, `1` when nothing is mapped for that directory, `2` when the command could not answer (a usage error, a malformed URL, an unreadable `~/.formio/projects.json`), and `3` when a project resolved but its Base URL could not be determined — so a caller can tell "nothing here yet" from "this failed" from "half configured, and here is the one value missing". `project set --cwd ` exits `0` when the directory is ready to serve a call, `1` when a named value is still missing, `2` when the command could not answer, and `3` when the record WAS written and the directory still resolves no Base URL — a committed `formio.json` governs it and supplies none, so the remedy is an edit to that file rather than another write. +\* Not at startup — the server starts, lists every tool, and answers `server_status` without it; only the tools that read or write Form.io data error, naming `project_set` and this variable. The alternative is the `project_set` tool, which maps a working directory to a project in `~/.formio/projects.json`. Resolution runs by scope, narrowest first: a committed `formio.json` found by walking up from the caller's `cwd`, then the mapping for that `cwd`, then `FORMIO_PROJECT_URL` in the environment as the weakest source, then the error. Map a directory before any client connects with `npx -y @formio/mcp@0.14.1 project set --project-url --cwd ` — the deployment is derived from the project URL wherever it can be, so add `--base-url ` only when the server says it cannot be determined. Add `--force` beside both URLs to record a pair the domain rules refuse — the one shape they cannot tell from a mistake is an internal deployment served from a `*.form.io` domain; it takes both URLs in the same call, is honoured on every later read, and is a shell-only flag the `project_set` tool does not have. `project set --reset --cwd ` clears a directory's entry, which is how a forced pair is un-forced: a write that leaves both halves untouched keeps the override, so there is nothing for an unforced re-record to change. `project get --cwd ` prints what resolves and which source won. It exits `0` when it resolved, `1` when nothing is mapped for that directory, `2` when the command could not answer (a usage error, a malformed URL, an unreadable `~/.formio/projects.json`), and `3` when a project resolved but its Base URL could not be determined — so a caller can tell "nothing here yet" from "this failed" from "half configured, and here is the one value missing". `project set --cwd ` exits `0` when the directory is ready to serve a call, `1` when a named value is still missing, `2` when the command could not answer, and `3` when the record WAS written and the directory still resolves no Base URL — a committed `formio.json` governs it and supplies none, so the remedy is an edit to that file rather than another write. + +--- + +## CLI reference: `project get` and `project set` + +The `formio-mcp` binary (`npx -y @formio/mcp@0.14.1 project …`) reports what a directory resolves to and records a project for it before any client connects. These flags and exit codes are part of the 1.0 surface. + +| Flag | Command | Meaning | +| --- | --- | --- | +| `--cwd ` | both | The directory to report on or record for. Defaults to the current directory; a relative path is refused. | +| `--project-url ` | `project set` | The Project URL to record. | +| `--base-url ` | `project set` | The deployment hosting the project. Pass it only when the server says it cannot be derived from the Project URL. | +| `--force` | `project set` | Record the pair exactly as given, skipping the domain checks. Requires both `--project-url` and `--base-url` in the same call. The `project_set` tool has no equivalent. | +| `--reset` | `project set` | Clear this directory's entry in `~/.formio/projects.json`. Takes no URLs. | + +| Exit code | `project get` | `project set` | +| --- | --- | --- | +| `0` | The directory resolved a project and its Base URL. | The directory is ready to serve a call. | +| `1` | Nothing is mapped for that directory. | A named value is still missing. | +| `2` | The command could not answer: a usage error, a malformed URL, an unreadable `~/.formio/projects.json`. | The command could not answer. | +| `3` | A project resolved, but its Base URL could not be determined. | The record was written, and the directory still resolves no Base URL: a committed `formio.json` governs it and supplies none, so the remedy is an edit to that file. | --- diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 47f0a432..2228d57a 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -25,10 +25,12 @@ "url": "git+https://github.com/formio/ai.git", "directory": "packages/mcp-server" }, - "main": "./dist/server.js", "bin": { "formio-mcp": "./dist/stdio.js" }, + "exports": { + "./package.json": "./package.json" + }, "files": [ "dist", "README.md" diff --git a/packages/mcp-server/src/__tests__/action_create.test.ts b/packages/mcp-server/src/__tests__/action_create.test.ts index 1ce236b8..f738b23b 100644 --- a/packages/mcp-server/src/__tests__/action_create.test.ts +++ b/packages/mcp-server/src/__tests__/action_create.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; const mockFormioFetch = vi.fn(); vi.mock('../formio-client.js', async (importOriginal) => { @@ -129,6 +130,7 @@ describe('action_create tool', () => { ), }), ]); + expect(result._meta?.[ERROR_META_KEY]).toEqual({ code: 'UNKNOWN_ACTION_TYPE' }); // Should NOT have attempted the POST expect(mockFormioFetch).toHaveBeenCalledTimes(1); }); diff --git a/packages/mcp-server/src/__tests__/action_list.test.ts b/packages/mcp-server/src/__tests__/action_list.test.ts index ad5793a3..dd09e43a 100644 --- a/packages/mcp-server/src/__tests__/action_list.test.ts +++ b/packages/mcp-server/src/__tests__/action_list.test.ts @@ -12,13 +12,19 @@ vi.mock('../formio-client.js', async (importOriginal) => { const { registerActionListTool } = await import('../tools/action_list.js'); +const FORM_ID = '67890abcdef012345678abcd'; + +function actions(count: number) { + return Array.from({ length: count }, (_, index) => ({ _id: `${index}`, name: 'email' })); +} + describe('action_list tool', () => { beforeEach(() => { mockFormioFetch.mockReset(); + mockFormioFetch.mockResolvedValue({ data: [], total: 0 }); }); it('is listed in available tools with formId parameter', async () => { - mockFormioFetch.mockResolvedValue([]); const { client } = await createTestClient(registerActionListTool); const { tools } = await client.listTools(); const tool = tools.find((t) => t.name === 'action_list'); @@ -27,34 +33,67 @@ describe('action_list tool', () => { expect(tool!.inputSchema.required).toContain('formId'); }); - it('sends GET to /form/{formId}/action and returns action instances array', async () => { - const formId = '67890abcdef012345678abcd'; - const actions = [ - { _id: 'aaa', name: 'email', title: 'Send Email' }, - { _id: 'bbb', name: 'save', title: 'Save Submission' }, - ]; - mockFormioFetch.mockResolvedValue(actions); + it('sends GET to /form/{formId}/action with limit 100 and skip 0, returning every action', async () => { + const page = actions(11); + mockFormioFetch.mockResolvedValue({ data: page, total: 11 }); const { client } = await createTestClient(registerActionListTool); const result = await client.callTool({ name: 'action_list', - arguments: { cwd: TEST_CWD, formId }, + arguments: { cwd: TEST_CWD, formId: FORM_ID }, }); - expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}/action`, {}, TEST_CONFIG); - expect(result.structuredContent).toEqual({ actions, count: 2 }); + expect(mockFormioFetch).toHaveBeenCalledWith( + `form/${FORM_ID}/action`, + { limit: '100', skip: '0' }, + TEST_CONFIG, + { withMeta: true } + ); + expect(result.structuredContent).toEqual({ actions: page, total: 11, hasMore: false }); expect(result.content).toEqual([ - { type: 'text', text: JSON.stringify({ actions, count: 2 }, null, 2) }, + { + type: 'text', + text: JSON.stringify({ actions: page, total: 11, hasMore: false }, null, 2), + }, ]); }); + it('forwards sort and select', async () => { + const { client } = await createTestClient(registerActionListTool); + + await client.callTool({ + name: 'action_list', + arguments: { cwd: TEST_CWD, formId: FORM_ID, sort: '-priority', select: 'name,priority' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith( + `form/${FORM_ID}/action`, + { limit: '100', skip: '0', sort: '-priority', select: 'name,priority' }, + TEST_CONFIG, + { withMeta: true } + ); + }); + + it('answers a skip past the end with no actions and the total, not an error', async () => { + mockFormioFetch.mockResolvedValue({ data: [], total: 3 }); + const { client } = await createTestClient(registerActionListTool); + + const result = await client.callTool({ + name: 'action_list', + arguments: { cwd: TEST_CWD, formId: FORM_ID, skip: 500 }, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ actions: [], total: 3, hasMore: false }); + }); + it('returns MCP error on API failure', async () => { mockFormioFetch.mockRejectedValue(new Error('Form.io API error: 500')); const { client } = await createTestClient(registerActionListTool); const result = await client.callTool({ name: 'action_list', - arguments: { cwd: TEST_CWD, formId: 'nonexistent0000000000000' }, + arguments: { cwd: TEST_CWD, formId: '0000000000000000000000ff' }, }); expect(result.isError).toBe(true); diff --git a/packages/mcp-server/src/__tests__/action_type_get.test.ts b/packages/mcp-server/src/__tests__/action_type_get.test.ts index f7e28ad6..dcbcb2a3 100644 --- a/packages/mcp-server/src/__tests__/action_type_get.test.ts +++ b/packages/mcp-server/src/__tests__/action_type_get.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; const mockFormioFetch = vi.fn(); vi.mock('../formio-client.js', async (importOriginal) => { @@ -80,6 +81,25 @@ describe('action_type_get tool', () => { ), }), ]); + expect(result._meta?.[ERROR_META_KEY]).toEqual({ code: 'UNKNOWN_ACTION_TYPE' }); + }); + + it('keeps the original error when the type is in the catalog', async () => { + const formId = '67890abcdef012345678abcd'; + mockFormioFetch.mockRejectedValueOnce(new Error('Form.io API error: 500')); + mockFormioFetch.mockResolvedValueOnce([{ name: 'email', title: 'Email' }]); + const { client } = await createTestClient(registerActionTypeGetTool); + + const result = await client.callTool({ + name: 'action_type_get', + arguments: { cwd: TEST_CWD, formId, actionName: 'email' }, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + expect.objectContaining({ type: 'text', text: expect.stringContaining('500') }), + ]); + expect(result._meta?.[ERROR_META_KEY]).not.toEqual({ code: 'UNKNOWN_ACTION_TYPE' }); }); it('returns original error when both type fetch and catalog fetch fail', async () => { diff --git a/packages/mcp-server/src/__tests__/action_types_list.test.ts b/packages/mcp-server/src/__tests__/action_type_list.test.ts similarity index 66% rename from packages/mcp-server/src/__tests__/action_types_list.test.ts rename to packages/mcp-server/src/__tests__/action_type_list.test.ts index 83112535..5bae02f0 100644 --- a/packages/mcp-server/src/__tests__/action_types_list.test.ts +++ b/packages/mcp-server/src/__tests__/action_type_list.test.ts @@ -10,51 +10,58 @@ vi.mock('../formio-client.js', async (importOriginal) => { }; }); -const { registerActionTypesListTool } = await import('../tools/action_types_list.js'); +const { registerActionTypeListTool } = await import('../tools/action_type_list.js'); -describe('action_types_list tool', () => { +describe('action_type_list tool', () => { beforeEach(() => { mockFormioFetch.mockReset(); }); - it('is listed in available tools with formId parameter', async () => { + it('is listed with a formId parameter and no paging arguments', async () => { mockFormioFetch.mockResolvedValue([]); - const { client } = await createTestClient(registerActionTypesListTool); + const { client } = await createTestClient(registerActionTypeListTool); const { tools } = await client.listTools(); - const tool = tools.find((t) => t.name === 'action_types_list'); + const tool = tools.find((t) => t.name === 'action_type_list'); expect(tool).toBeDefined(); expect(tool!.inputSchema.properties).toHaveProperty('formId'); expect(tool!.inputSchema.required).toContain('formId'); + for (const paging of ['limit', 'skip', 'sort', 'select']) { + expect(tool!.inputSchema.properties).not.toHaveProperty(paging); + } }); - it('sends GET to /form/{formId}/actions and returns catalog array', async () => { + // Form.io does not page this route, so the whole catalog is one answer. + it('sends GET to /form/{formId}/actions and returns the whole catalog', async () => { const formId = '67890abcdef012345678abcd'; const catalog = [ { name: 'email', title: 'Email' }, { name: 'save', title: 'Save Submission' }, ]; mockFormioFetch.mockResolvedValue(catalog); - const { client } = await createTestClient(registerActionTypesListTool); + const { client } = await createTestClient(registerActionTypeListTool); const result = await client.callTool({ - name: 'action_types_list', + name: 'action_type_list', arguments: { cwd: TEST_CWD, formId }, }); expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}/actions`, {}, TEST_CONFIG); - expect(result.structuredContent).toEqual({ actionTypes: catalog, count: 2 }); + expect(result.structuredContent).toEqual({ actionTypes: catalog, total: 2, hasMore: false }); expect(result.content).toEqual([ - { type: 'text', text: JSON.stringify({ actionTypes: catalog, count: 2 }, null, 2) }, + { + type: 'text', + text: JSON.stringify({ actionTypes: catalog, total: 2, hasMore: false }, null, 2), + }, ]); }); it('returns MCP error on API failure', async () => { mockFormioFetch.mockRejectedValue(new Error('Form.io API error: 404')); - const { client } = await createTestClient(registerActionTypesListTool); + const { client } = await createTestClient(registerActionTypeListTool); const result = await client.callTool({ - name: 'action_types_list', - arguments: { cwd: TEST_CWD, formId: 'nonexistent0000000000000' }, + name: 'action_type_list', + arguments: { cwd: TEST_CWD, formId: '0000000000000000000000ff' }, }); expect(result.isError).toBe(true); diff --git a/packages/mcp-server/src/__tests__/auth-failure-codes.test.ts b/packages/mcp-server/src/__tests__/auth-failure-codes.test.ts new file mode 100644 index 00000000..5a0b814d --- /dev/null +++ b/packages/mcp-server/src/__tests__/auth-failure-codes.test.ts @@ -0,0 +1,109 @@ +// A browser login that cannot complete — no browser, or no login before the timeout — +// leaves the call without a credential, so it is AUTH_REQUIRED. A failure with a cause +// of its own keeps that cause's code: a login form that could not be resolved keeps +// the code it was raised with, and a login port that cannot be bound is INTERNAL with +// the cause in the message. Each keeps its own prose, which tells the user what to do. + +import net from 'node:net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { authenticate, loginFormFailed } from '../auth.js'; +import { FormioNetworkError, ToolError } from '../tool-errors.js'; +import { ResolvedFormioConfig } from '../config.js'; + +vi.mock('child_process', () => ({ execFile: vi.fn() })); + +const CONFIG: ResolvedFormioConfig = { + baseUrl: 'https://formio.invalid/sub', + projectUrl: 'https://formio.invalid/sub/example', + forceBrowser: true, +}; + +describe('a failed browser login carries the code of its cause', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + vi.spyOn(process.stderr, 'write').mockImplementation((): boolean => true); + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('on a host with no browser', async () => { + delete process.env.SSH_CONNECTION; + delete process.env.SSH_TTY; + process.env.CI = 'true'; + + const attempt = authenticate({ ...CONFIG, forceBrowser: false }); + + await expect(attempt).rejects.toMatchObject({ code: 'AUTH_REQUIRED' }); + await expect(attempt).rejects.toThrow(/Cannot complete the Form\.io browser login/); + }); + + it('when no login arrives before the timeout', async () => { + const attempt = authenticate({ ...CONFIG, authTimeoutMs: 50 }); + + await expect(attempt).rejects.toMatchObject({ code: 'AUTH_REQUIRED' }); + await expect(attempt).rejects.toThrow(/Timed out after/); + }); + + it('when the login port cannot be bound', async () => { + const squatter = net.createServer(); + await new Promise((resolve) => squatter.listen(0, '127.0.0.1', resolve)); + const port = (squatter.address() as net.AddressInfo).port; + try { + const attempt = authenticate({ + ...CONFIG, + authHost: '127.0.0.1', + authPort: port, + authTimeoutMs: 60_000, + }); + + await expect(attempt).rejects.toMatchObject({ code: 'INTERNAL' }); + await expect(attempt).rejects.toThrow(/Could not start the Form\.io login server/); + await expect(attempt).rejects.toThrow(/EADDRINUSE/); + } finally { + await new Promise((resolve) => squatter.close(() => resolve())); + } + }); + + it('when the login form cannot be resolved', async () => { + const attempt = authenticate( + { ...CONFIG, loginFormUrl: 'not a url', authTimeoutMs: 60_000 }, + { + onReady: (port) => { + void fetch(`http://127.0.0.1:${port}/`).catch(() => undefined); + }, + } + ); + + await expect(attempt).rejects.toMatchObject({ code: 'INTERNAL' }); + await expect(attempt).rejects.toThrow(/Invalid URL/); + }); +}); + +describe('loginFormFailed', () => { + it.each([ + [ + 'NETWORK_ERROR', + new FormioNetworkError({ url: 'https://formio.invalid/x', cause: new Error('ECONNREFUSED') }), + ], + ['NOT_FOUND', new ToolError({ code: 'NOT_FOUND', message: 'no login form' })], + ['UPSTREAM_ERROR', new ToolError({ code: 'UPSTREAM_ERROR', message: '502' })], + ])('keeps %s from a ToolError', (code, error) => { + const failure = loginFormFailed(error); + + expect(failure).toMatchObject({ code }); + expect(failure.message).toContain(error.message); + expect(failure.cause).toBe(error); + }); + + it('is INTERNAL for any other error, with its message', () => { + const failure = loginFormFailed(new TypeError('Invalid URL')); + + expect(failure).toMatchObject({ code: 'INTERNAL' }); + expect(failure.message).toContain('Invalid URL'); + }); +}); diff --git a/packages/mcp-server/src/__tests__/auth-urls-come-from-the-deployment.test.ts b/packages/mcp-server/src/__tests__/auth-urls-come-from-the-deployment.test.ts index aaf3408e..41a88484 100644 --- a/packages/mcp-server/src/__tests__/auth-urls-come-from-the-deployment.test.ts +++ b/packages/mcp-server/src/__tests__/auth-urls-come-from-the-deployment.test.ts @@ -122,10 +122,9 @@ describe('auth URLs are built from the deployment, not the project’s host', () ); const { gateRevisionsLicense } = await import('../revisions/license.js'); - const server = { server: { createMessage: undefined } } as never; - await gateRevisionsLicense(server, config, { + await gateRevisionsLicense({ + cfg: config, actionLabel: 'create', - requiresRevisions: false, form: { title: 'Contact' }, }).catch(() => undefined); diff --git a/packages/mcp-server/src/__tests__/boolean-env.test.ts b/packages/mcp-server/src/__tests__/boolean-env.test.ts new file mode 100644 index 00000000..871c69af --- /dev/null +++ b/packages/mcp-server/src/__tests__/boolean-env.test.ts @@ -0,0 +1,71 @@ +/** + * Every boolean environment variable the server reads has one spelling rule: + * after trimming, `true` or `1` in any case is true, and anything else — unset + * included — is false. Two variables reading the same value differently is a + * setting that works for one and is silently ignored by the other. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getConfig, readBooleanEnv } from '../config.js'; + +const TRUE_SPELLINGS = ['true', 'TRUE', 'True', ' 1 ', '1', ' true ']; +const FALSE_SPELLINGS: (string | undefined)[] = [undefined, '', '0', 'false', 'yes', 'on']; + +describe('readBooleanEnv', () => { + it.each(TRUE_SPELLINGS)('reads %j as true', (raw) => { + expect(readBooleanEnv(raw)).toBe(true); + }); + + it.each(FALSE_SPELLINGS)('reads %j as false', (raw) => { + expect(readBooleanEnv(raw)).toBe(false); + }); +}); + +describe('boolean environment variables', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.FORMIO_FORCE_BROWSER; + delete process.env.FORMIO_INSECURE_TLS; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + function setOrClear(name: string, raw: string | undefined) { + if (raw === undefined) { + delete process.env[name]; + } else { + process.env[name] = raw; + } + } + + // FORMIO_INSECURE_TLS is applied once, when the client module loads. + async function insecureTlsAppliedFor(raw: string | undefined): Promise { + setOrClear('FORMIO_INSECURE_TLS', raw); + vi.resetModules(); + await import('../formio-client.js'); + return process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0'; + } + + it.each(TRUE_SPELLINGS)('FORMIO_FORCE_BROWSER=%j forces the browser', (raw) => { + setOrClear('FORMIO_FORCE_BROWSER', raw); + expect(getConfig().forceBrowser).toBe(true); + }); + + it.each(FALSE_SPELLINGS)('FORMIO_FORCE_BROWSER=%j leaves the browser unforced', (raw) => { + setOrClear('FORMIO_FORCE_BROWSER', raw); + expect(getConfig().forceBrowser).toBe(false); + }); + + it.each(TRUE_SPELLINGS)('FORMIO_INSECURE_TLS=%j disables certificate checks', async (raw) => { + expect(await insecureTlsAppliedFor(raw)).toBe(true); + }); + + it.each(FALSE_SPELLINGS)('FORMIO_INSECURE_TLS=%j leaves certificate checks on', async (raw) => { + expect(await insecureTlsAppliedFor(raw)).toBe(false); + }); +}); diff --git a/packages/mcp-server/src/__tests__/browser-prompts-launch.test.ts b/packages/mcp-server/src/__tests__/browser-prompts-launch.test.ts deleted file mode 100644 index 3ab79906..00000000 --- a/packages/mcp-server/src/__tests__/browser-prompts-launch.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { execFile } from 'child_process'; -import { browserLaunchCommand } from '../browser-launch.js'; -import { requestRevisionsLicenseConsent } from '../revisions/browser-prompts.js'; - -vi.mock('child_process', () => ({ execFile: vi.fn() })); - -function postChoice(port: number, choice: string): Promise { - return fetch(`http://127.0.0.1:${port}/callback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ choice }), - }); -} - -describe('opening the revisions consent page', () => { - let stderr: string[]; - - beforeEach(() => { - stderr = []; - vi.spyOn(process.stderr, 'write').mockImplementation((chunk): boolean => { - stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); - return true; - }); - vi.mocked(execFile).mockReset(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('launches the browser without a shell, the URL as an argument of its own', async () => { - let consentUrl = ''; - await requestRevisionsLicenseConsent('a deployment', 'an action', { - onReady: async (port) => { - consentUrl = `http://127.0.0.1:${port}/`; - await postChoice(port, 'continue'); - }, - }); - - const { command, args } = browserLaunchCommand(consentUrl); - const [file, passedArgs] = vi.mocked(execFile).mock.calls[0] ?? []; - expect(file).toBe(command); - expect(passedArgs).toEqual(args); - }); - - // The launch failure was previously ignored, so a user whose browser never - // opened had no URL to open by hand. - it('names the consent URL when the browser cannot be launched', async () => { - vi.mocked(execFile).mockImplementation((( - _file: string, - _args: readonly string[], - _options: unknown, - cb?: (err: Error | null) => void - ): unknown => { - cb?.(new Error('spawn xdg-open ENOENT')); - return {}; - }) as unknown as typeof execFile); - - let consentUrl = ''; - await requestRevisionsLicenseConsent('a deployment', 'an action', { - onReady: async (port) => { - consentUrl = `http://127.0.0.1:${port}/`; - await postChoice(port, 'continue'); - }, - }); - - const logged = stderr.join(''); - expect(logged).toMatch(/could not open a browser/i); - expect(logged).toContain(consentUrl); - }); -}); diff --git a/packages/mcp-server/src/__tests__/capability-quality.test.ts b/packages/mcp-server/src/__tests__/capability-quality.test.ts index e147f6d8..59649895 100644 --- a/packages/mcp-server/src/__tests__/capability-quality.test.ts +++ b/packages/mcp-server/src/__tests__/capability-quality.test.ts @@ -47,7 +47,7 @@ describe('capability quality', () => { const tools = await listToolsUnconfigured(); // The whole surface, project_set included: every client can map a working // directory to a project, so nothing is withheld by launch mode. - expect(tools.length).toBe(21); + expect(tools.length).toBe(23); expect(tools.map((t) => t.name)).toContain('form_list'); expect(tools.map((t) => t.name)).toContain('project_set'); // The read half of the same surface. Withholding it is what sent every @@ -56,6 +56,18 @@ describe('capability quality', () => { expect(tools.map((t) => t.name)).toContain('project_get'); }); + // Tool names are singular nouns, and the connectivity check reports something a + // caller can act on. The old names are gone rather than aliased. + it('lists the 1.0 tool names and none of the names they replaced', async () => { + const names = (await listToolsUnconfigured()).map((t) => t.name); + expect(names).toEqual( + expect.arrayContaining(['action_type_list', 'form_revision_list', 'server_status']) + ); + expect(names).not.toContain('action_types_list'); + expect(names).not.toContain('form_revisions_list'); + expect(names).not.toContain('hello'); + }); + it('still reports a missing project URL clearly when a tool is called', async () => { const server = createServer(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -134,7 +146,14 @@ describe('capability quality', () => { for (const name of ['form_list', 'form_get', 'role_list', 'action_list', 'project_export']) { expect(byName.get(name)!.annotations!.readOnlyHint, `${name} should be read-only`).toBe(true); } - for (const name of ['form_create', 'form_update', 'role_create', 'project_import']) { + for (const name of [ + 'form_create', + 'form_update', + 'form_publish', + 'form_revert', + 'role_create', + 'project_import', + ]) { expect(byName.get(name)!.annotations!.readOnlyHint, `${name} writes`).toBe(false); } }); diff --git a/packages/mcp-server/src/__tests__/every-tool-error-contract.test.ts b/packages/mcp-server/src/__tests__/every-tool-error-contract.test.ts new file mode 100644 index 00000000..7b3cca59 --- /dev/null +++ b/packages/mcp-server/src/__tests__/every-tool-error-contract.test.ts @@ -0,0 +1,120 @@ +// Every registered tool reports a failure the same way: `[CODE] message` in the text, +// with CODE one of the shared codes, and the code under `_meta['io.form/error']`. One +// error path is driven per tool, against the full registry, so a tool added later +// without a case fails here rather than shipping a failure that carries no code. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ERROR_META_KEY } from '../mcp-responses.js'; +import { TOOL_ERROR_CODES } from '../tool-errors.js'; +import { registerAllTools } from '../tools/index.js'; +import { connectTools } from './test-helpers.js'; + +interface ToolResult { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + _meta?: Record; +} + +const UNCONFIGURED = '/workspace/every-tool-unconfigured'; +const FORM_ID = '67890abcdef012345678abcd'; +const OBJECT_ID = '69d68310040fa2cea2572945'; +const ACTION = { name: 'email', title: 'Email', handler: ['after'], method: ['create'] }; + +// The project-scoped tools fail on a directory with no project, before any request. +const PROJECT_SCOPED: Record> = { + form_create: { form: { title: 'T', name: 't', path: 't', components: [] } }, + form_get: { formIdOrPath: 'contact' }, + form_list: {}, + form_revision_get: { formIdOrPath: 'contact', version: '1' }, + form_revision_list: { formIdOrPath: 'contact' }, + form_update: { formId: FORM_ID, form: { components: [] }, note: 'n' }, + form_publish: { formId: FORM_ID, note: 'n' }, + form_revert: { formId: FORM_ID, version: '1', note: 'n' }, + project_export: {}, + project_import: { template: {} }, + role_create: { role: { title: 'R' } }, + role_list: {}, + role_update: { roleId: OBJECT_ID, role: { title: 'R' } }, + action_type_list: { formId: FORM_ID }, + action_type_get: { formId: FORM_ID, actionName: 'email' }, + action_create: { formId: FORM_ID, action: ACTION }, + action_list: { formId: FORM_ID }, + action_get: { formId: FORM_ID, actionId: OBJECT_ID }, + action_update: { formId: FORM_ID, actionId: OBJECT_ID, action: ACTION }, + action_delete: { formId: FORM_ID, actionId: OBJECT_ID }, +}; + +// The local tools make no request; each fails on its own input or on an unreadable +// project map. +const LOCAL: Record; unreadableMap?: boolean }> = { + project_set: { args: { projectUrl: 'not a url', cwd: UNCONFIGURED } }, + project_get: { args: { cwd: UNCONFIGURED }, unreadableMap: true }, + server_status: { args: { cwd: UNCONFIGURED }, unreadableMap: true }, +}; + +// Tools that cannot fail on any input, each with the reason. Empty today. +const CANNOT_ERROR: Record = {}; + +function breakProjectMap(): void { + const dir = path.join(os.homedir(), '.formio'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'projects.json'), '{ not json', 'utf8'); +} + +async function callTool(name: string, args: Record): Promise { + const client = await connectTools((server) => registerAllTools(server, {})); + return (await client.callTool({ name, arguments: args })) as ToolResult; +} + +function expectCodedError(result: ToolResult): void { + const text = result.content.map((part) => part.text ?? '').join('\n'); + const code = result._meta?.[ERROR_META_KEY]?.code; + expect(result.isError).toBe(true); + expect(TOOL_ERROR_CODES).toContain(code); + expect(text.startsWith(`[${code}] `), text).toBe(true); +} + +describe('every tool reports a failure with a code', () => { + beforeEach(() => { + // No case here reaches Form.io; a request would mean the error path was missed. + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('unexpected request'); + }) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('has a case, or a stated reason, for every registered tool', async () => { + const client = await connectTools((server) => registerAllTools(server, {})); + const { tools } = await client.listTools(); + const covered = [ + ...Object.keys(PROJECT_SCOPED), + ...Object.keys(LOCAL), + ...Object.keys(CANNOT_ERROR), + ]; + expect(tools.map((tool) => tool.name).sort()).toEqual(covered.sort()); + }); + + it.each(Object.entries(PROJECT_SCOPED))('%s', async (name, args) => { + const result = await callTool(name, { cwd: UNCONFIGURED, ...args }); + + expectCodedError(result); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('NOT_CONFIGURED'); + }); + + it.each(Object.entries(LOCAL))('%s', async (name, { args, unreadableMap }) => { + if (unreadableMap) { + breakProjectMap(); + } + + expectCodedError(await callTool(name, args)); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form-id-argument.test.ts b/packages/mcp-server/src/__tests__/form-id-argument.test.ts new file mode 100644 index 00000000..5721df11 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form-id-argument.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createTestClient, stubRevisionsLicence, TEST_CWD } from './test-helpers.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerAllTools } = await import('../tools/index.js'); + +const FORM_ID = '65a1b2c3d4e5f60718293a4b'; +const ACTION_ID = '65a1b2c3d4e5f60718293a4c'; +const ACTION = { name: 'email', title: 'Email', handler: ['after'], method: ['create'] }; + +// Every tool whose `formId` names a form by its _id, with valid values for its other +// required arguments, so a refusal is about `formId` alone. +const FORM_ID_TOOLS: Record> = { + action_list: {}, + action_get: { actionId: ACTION_ID }, + action_create: { action: ACTION }, + action_update: { actionId: ACTION_ID, action: ACTION }, + action_delete: { actionId: ACTION_ID }, + action_type_list: {}, + action_type_get: { actionName: 'email' }, + form_update: { form: { components: [] }, note: 'n', acceptNoHistory: true }, + form_publish: { note: 'n' }, + form_revert: { version: '3', note: 'n' }, +}; + +interface ToolResult { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + _meta?: Record; +} + +function textOf(result: ToolResult): string { + return result.content.map((item) => item.text ?? '').join('\n'); +} + +describe('formId takes a form _id on every tool that has it', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + mockFormioFetch.mockResolvedValue({}); + stubRevisionsLicence(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('every registered formId argument is covered here', async () => { + const { client } = await createTestClient(registerAllTools); + const { tools } = await client.listTools(); + const withFormId = tools + .filter((tool) => Object.keys(tool.inputSchema.properties ?? {}).includes('formId')) + .map((tool) => tool.name) + .sort(); + expect(withFormId).toEqual(Object.keys(FORM_ID_TOOLS).sort()); + }); + + it.each(['user/login', 'user', '67890abc', 'zzzzzzzzzzzzzzzzzzzzzzzz'])( + 'refuses formId %j with INVALID_ARGUMENT and the form_get remedy, before any request', + async (formId) => { + const { client } = await createTestClient(registerAllTools); + const failures: string[] = []; + for (const [tool, args] of Object.entries(FORM_ID_TOOLS)) { + mockFormioFetch.mockClear(); + const result = (await client.callTool({ + name: tool, + arguments: { cwd: TEST_CWD, ...args, formId }, + })) as ToolResult; + const text = textOf(result); + const refusedWithRemedy = + result.isError === true && + result._meta?.[ERROR_META_KEY]?.code === 'INVALID_ARGUMENT' && + text.startsWith('[INVALID_ARGUMENT] ') && + text.includes('formId') && + text.includes('form_get') && + text.includes('formIdOrPath') && + mockFormioFetch.mock.calls.length === 0; + if (!refusedWithRemedy) { + failures.push(`${tool}: ${text}`); + } + } + expect(failures).toEqual([]); + } + ); + + it('accepts an upper-case ObjectId', async () => { + const { client } = await createTestClient(registerAllTools); + await client.callTool({ + name: 'action_get', + arguments: { cwd: TEST_CWD, formId: FORM_ID.toUpperCase(), actionId: ACTION_ID }, + }); + expect(mockFormioFetch).toHaveBeenCalledWith( + `form/${FORM_ID.toUpperCase()}/action/${ACTION_ID}`, + expect.anything(), + expect.anything() + ); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_create.test.ts b/packages/mcp-server/src/__tests__/form_create.test.ts index 309935ff..5a570fc9 100644 --- a/packages/mcp-server/src/__tests__/form_create.test.ts +++ b/packages/mcp-server/src/__tests__/form_create.test.ts @@ -1,28 +1,28 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createTestClient, + freshProjectUrl, + stubRevisionsLicence, + TEST_CONFIG, + TEST_CWD, +} from './test-helpers.js'; const mockFormioFetch = vi.fn(); -vi.mock('../formio-client.js', () => ({ +vi.mock('../formio-client.js', async (importOriginal) => ({ + ...(await importOriginal()), 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', () => { beforeEach(() => { mockFormioFetch.mockReset(); + stubRevisionsLicence(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); it('is listed in available tools with skill-referencing description', async () => { @@ -146,4 +146,29 @@ describe('form_create tool', () => { body: { revisions: 'original', ...form, _vnote: '@formio/mcp: initial' }, }); }); + + // A licence probe that produced no answer establishes nothing, so the body goes as + // written: the licensed default is not added, and the caller's setting is not + // stripped. + it.each([ + ['without a revisions setting', {}], + ['with a revisions setting', { revisions: 'current' as const }], + ])('sends the body as written %s when the licence is unknown', async (_label, extra) => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))); + mockFormioFetch.mockResolvedValue({ _id: '123' }); + const projectUrl = freshProjectUrl(); + const { client } = await createTestClient(registerFormCreateTool, { projectUrl }); + + const form = { title: 'T', name: 't', path: 't', components: [], ...extra }; + const result = await client.callTool({ + name: 'form_create', + arguments: { cwd: TEST_CWD, form }, + }); + + expect(result.isError ?? false).toBe(false); + expect(mockFormioFetch).toHaveBeenCalledWith('form', {}, expect.anything(), { + method: 'POST', + body: form, + }); + }); }); diff --git a/packages/mcp-server/src/__tests__/form_get.test.ts b/packages/mcp-server/src/__tests__/form_get.test.ts index 1f3a1365..507dea0f 100644 --- a/packages/mcp-server/src/__tests__/form_get.test.ts +++ b/packages/mcp-server/src/__tests__/form_get.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; const mockFormioFetch = vi.fn(); vi.mock('../formio-client.js', async (importOriginal) => { @@ -145,8 +146,11 @@ describe('form_get tool', () => { expect(result.isError).toBe(true); expect(result.content).toEqual([ - expect.objectContaining({ text: expect.stringMatching(/No draft exists/) }), + expect.objectContaining({ text: expect.stringMatching(/^\[NO_DRAFT\] No draft exists/) }), ]); + expect((result._meta as Record)[ERROR_META_KEY]?.code).toBe( + 'NO_DRAFT' + ); }); }); diff --git a/packages/mcp-server/src/__tests__/form_list.test.ts b/packages/mcp-server/src/__tests__/form_list.test.ts index 3ceb364b..46cb857d 100644 --- a/packages/mcp-server/src/__tests__/form_list.test.ts +++ b/packages/mcp-server/src/__tests__/form_list.test.ts @@ -8,134 +8,153 @@ vi.mock('../formio-client.js', () => ({ const { registerFormListTool } = await import('../tools/form_list.js'); +const DEFAULT_SELECT = '_id,title,name,path,type,tags'; + +function forms(count: number) { + return Array.from({ length: count }, (_, index) => ({ _id: `${index}`, title: `Form ${index}` })); +} + describe('form_list tool', () => { beforeEach(() => { mockFormioFetch.mockReset(); + mockFormioFetch.mockResolvedValue({ data: [], total: 0 }); }); it('is listed in available tools', async () => { - mockFormioFetch.mockResolvedValue([]); const { client } = await createTestClient(registerFormListTool); const { tools } = await client.listTools(); expect(tools.map((t) => t.name)).toContain('form_list'); }); - it('sends default select and limit when called with no arguments', async () => { - const forms = [{ _id: '1', title: 'Test Form' }]; - mockFormioFetch.mockResolvedValue(forms); + it('always sends limit 100 and skip 0 with the summary select when called with no arguments', async () => { const { client } = await createTestClient(registerFormListTool); await client.callTool({ name: 'form_list', arguments: { cwd: TEST_CWD } }); expect(mockFormioFetch).toHaveBeenCalledWith( 'form', - expect.objectContaining({ - select: '_id,title,name,path,type,tags', - limit: '20', - }), - TEST_CONFIG + { limit: '100', skip: '0', select: DEFAULT_SELECT }, + TEST_CONFIG, + { withMeta: true } ); }); - // The array is wrapped in a named field because structuredContent must be an - // object, and the text view mirrors it so the two do not disagree. - it('returns the forms as structured content and matching text', async () => { - const forms = [{ _id: '1', title: 'Test Form' }]; - mockFormioFetch.mockResolvedValue(forms); + it('returns the forms with total and hasMore, and no count', async () => { + const page = forms(2); + mockFormioFetch.mockResolvedValue({ data: page, total: 2 }); const { client } = await createTestClient(registerFormListTool); const result = await client.callTool({ name: 'form_list', arguments: { cwd: TEST_CWD } }); - expect(result.structuredContent).toEqual({ forms, count: 1 }); + expect(result.structuredContent).toEqual({ forms: page, total: 2, hasMore: false }); expect(result.content).toEqual([ - { type: 'text', text: JSON.stringify({ forms, count: 1 }, null, 2) }, + { + type: 'text', + text: JSON.stringify({ forms: page, total: 2, hasMore: false }, null, 2), + }, ]); }); - it('passes type parameter', async () => { - mockFormioFetch.mockResolvedValue([]); + it('pages through more forms than one page holds', async () => { const { client } = await createTestClient(registerFormListTool); - await client.callTool({ name: 'form_list', arguments: { cwd: TEST_CWD, type: 'resource' } }); + mockFormioFetch.mockResolvedValueOnce({ data: forms(100), total: 150 }); + const first = await client.callTool({ name: 'form_list', arguments: { cwd: TEST_CWD } }); + expect(first.structuredContent).toMatchObject({ total: 150, hasMore: true }); + expect((first.structuredContent as { forms: unknown[] }).forms).toHaveLength(100); - expect(mockFormioFetch).toHaveBeenCalledWith( - 'form', - expect.objectContaining({ type: 'resource' }), - TEST_CONFIG - ); - }); - - it('passes custom limit', async () => { - mockFormioFetch.mockResolvedValue([]); - const { client } = await createTestClient(registerFormListTool); - - await client.callTool({ name: 'form_list', arguments: { cwd: TEST_CWD, limit: 5 } }); - - expect(mockFormioFetch).toHaveBeenCalledWith( + mockFormioFetch.mockResolvedValueOnce({ data: forms(50), total: 150 }); + const second = await client.callTool({ + name: 'form_list', + arguments: { cwd: TEST_CWD, skip: 100 }, + }); + expect(second.structuredContent).toMatchObject({ total: 150, hasMore: false }); + expect((second.structuredContent as { forms: unknown[] }).forms).toHaveLength(50); + expect(mockFormioFetch).toHaveBeenLastCalledWith( 'form', - expect.objectContaining({ limit: '5' }), - TEST_CONFIG + expect.objectContaining({ limit: '100', skip: '100' }), + TEST_CONFIG, + { withMeta: true } ); }); - it('passes skip and limit for pagination', async () => { - mockFormioFetch.mockResolvedValue([]); + it('passes type parameter', async () => { const { client } = await createTestClient(registerFormListTool); - await client.callTool({ name: 'form_list', arguments: { cwd: TEST_CWD, skip: 20, limit: 10 } }); + await client.callTool({ name: 'form_list', arguments: { cwd: TEST_CWD, type: 'resource' } }); expect(mockFormioFetch).toHaveBeenCalledWith( 'form', - expect.objectContaining({ skip: '20', limit: '10' }), - TEST_CONFIG + expect.objectContaining({ type: 'resource' }), + TEST_CONFIG, + { withMeta: true } ); }); - it('passes sort parameter', async () => { - mockFormioFetch.mockResolvedValue([]); + it('forwards limit, skip, sort and select', async () => { const { client } = await createTestClient(registerFormListTool); - await client.callTool({ name: 'form_list', arguments: { cwd: TEST_CWD, sort: '-created' } }); + await client.callTool({ + name: 'form_list', + arguments: { cwd: TEST_CWD, limit: 10, skip: 20, sort: '-created', select: '_id,title' }, + }); expect(mockFormioFetch).toHaveBeenCalledWith( 'form', - expect.objectContaining({ sort: '-created' }), - TEST_CONFIG + { limit: '10', skip: '20', sort: '-created', select: '_id,title' }, + TEST_CONFIG, + { withMeta: true } ); }); - it('overrides default select when provided', async () => { - mockFormioFetch.mockResolvedValue([]); + it('filters by every given tag with tags__all', async () => { const { client } = await createTestClient(registerFormListTool); await client.callTool({ name: 'form_list', - arguments: { cwd: TEST_CWD, select: '_id,title,components' }, + arguments: { cwd: TEST_CWD, tags: ['crm', 'intake'] }, }); - expect(mockFormioFetch).toHaveBeenCalledWith( - 'form', - expect.objectContaining({ select: '_id,title,components' }), - TEST_CONFIG - ); + const params = mockFormioFetch.mock.calls[0][1] as Record; + expect(params.tags__all).toBe('crm,intake'); + expect(params.tags).toBeUndefined(); }); - it('joins tags array into comma-separated string', async () => { - mockFormioFetch.mockResolvedValue([]); + // Form.io splits tags__all on commas, so "a,b" would be read as two tags and match + // forms carrying both, never the one tag the caller named. + it('refuses a tag containing a comma with INVALID_ARGUMENT, without a request', async () => { const { client } = await createTestClient(registerFormListTool); - await client.callTool({ + const result = (await client.callTool({ name: 'form_list', - arguments: { cwd: TEST_CWD, tags: ['survey', 'public'] }, - }); + arguments: { cwd: TEST_CWD, tags: ['crm', 'sales,emea'] }, + })) as { + isError?: boolean; + content: Array<{ text?: string }>; + _meta?: Record; + }; - expect(mockFormioFetch).toHaveBeenCalledWith( - 'form', - expect.objectContaining({ tags: 'survey,public' }), - TEST_CONFIG - ); + expect(result.isError).toBe(true); + expect(result._meta?.['io.form/error']?.code).toBe('INVALID_ARGUMENT'); + expect(result.content[0].text).toContain('"sales,emea"'); + expect(mockFormioFetch).not.toHaveBeenCalled(); }); + it.each([{ limit: 0 }, { limit: -1 }, { limit: 1.5 }, { skip: -1 }, { skip: 0.5 }])( + 'refuses %o without a request', + async (args) => { + const { client } = await createTestClient(registerFormListTool); + + const result = await client.callTool({ + name: 'form_list', + arguments: { cwd: TEST_CWD, ...args }, + }); + + expect(result.isError).toBe(true); + expect(mockFormioFetch).not.toHaveBeenCalled(); + } + ); + it('returns isError true on API error', async () => { mockFormioFetch.mockRejectedValue(new Error('Form.io API error: 401 Unauthorized')); const { client } = await createTestClient(registerFormListTool); diff --git a/packages/mcp-server/src/__tests__/form_publish.test.ts b/packages/mcp-server/src/__tests__/form_publish.test.ts new file mode 100644 index 00000000..5a0ead34 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_publish.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createTestClient, + freshProjectUrl, + stubRevisionsLicence, + TEST_CWD, +} from './test-helpers.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormPublishTool } = await import('../tools/form_publish.js'); + +const FORM_ID = '67890abcdef012345678abcd'; + +interface ToolResult { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + structuredContent?: Record; + _meta?: Record; +} + +type FetchOptions = { method?: string; body?: unknown } | undefined; + +function puts() { + return mockFormioFetch.mock.calls.filter(([, , , options]) => (options as FetchOptions)?.method); +} + +async function publish(args: Record, projectUrl?: string) { + const { client } = await createTestClient(registerFormPublishTool, { projectUrl }); + return (await client.callTool({ + name: 'form_publish', + arguments: { cwd: TEST_CWD, ...args }, + })) as ToolResult; +} + +describe('form_publish tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + stubRevisionsLicence(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('takes the form, a note and the working directory, and no form body', async () => { + const { client } = await createTestClient(registerFormPublishTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_publish'); + expect(tool).toBeDefined(); + expect(Object.keys(tool!.inputSchema.properties ?? {}).sort()).toEqual([ + 'cwd', + 'formId', + 'note', + ]); + expect(tool!.inputSchema.required).toEqual(expect.arrayContaining(['formId', 'note'])); + expect(tool!.annotations?.readOnlyHint).toBe(false); + }); + + it("puts the live form overlaid with the draft's allowlisted fields and the note", async () => { + const draft = { + _vid: 'draft', + components: [{ type: 'phoneNumber', key: 'phone' }], + settings: { theme: 'dark' }, + title: 'NOT PUBLISHED', + access: [{ type: 'NOT PUBLISHED' }], + }; + const live = { _id: FORM_ID, title: 'Live', components: [], access: [{ type: 'read_all' }] }; + const published = { ...live, components: draft.components, settings: draft.settings }; + mockFormioFetch.mockImplementation( + (path: string, _p: unknown, _c: unknown, options: FetchOptions) => { + if (options?.method) return Promise.resolve({}); + if (path === `form/${FORM_ID}/draft`) return Promise.resolve(draft); + return Promise.resolve(puts().length ? published : live); + } + ); + + const result = await publish({ formId: FORM_ID, note: 'Add phone field' }); + + expect(result.isError).toBeFalsy(); + expect(puts()).toEqual([ + [ + `form/${FORM_ID}`, + {}, + expect.anything(), + { + method: 'PUT', + body: { + ...live, + components: draft.components, + settings: draft.settings, + _vnote: '@formio/mcp: Add phone field', + }, + }, + ], + ]); + expect(result.structuredContent).toEqual(published); + }); + + it('refuses with NO_DRAFT and sends no PUT when the form has no draft', async () => { + // GET /draft falls back to the live form when no draft exists. + mockFormioFetch.mockResolvedValue({ _id: FORM_ID, _vid: 4, components: [] }); + + const result = await publish({ formId: FORM_ID, note: 'n' }); + + expect(result.isError).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('NO_DRAFT'); + expect(result.content[0].text).toMatch(/^\[NO_DRAFT\] /); + expect(result.content[0].text).toMatch(/draft: true/); + expect(puts()).toEqual([]); + }); + + it('refuses with LICENSE_REQUIRED on a deployment without the revisions licence', async () => { + stubRevisionsLicence(false); + + const result = await publish({ formId: FORM_ID, note: 'n' }, freshProjectUrl()); + + expect(result.isError).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('LICENSE_REQUIRED'); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + it('refuses a formId that is not an ObjectId before any request', async () => { + const result = await publish({ formId: 'user/login', note: 'n' }); + + expect(result.isError).toBe(true); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_revert.test.ts b/packages/mcp-server/src/__tests__/form_revert.test.ts new file mode 100644 index 00000000..21ffb8a9 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revert.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createTestClient, + freshProjectUrl, + stubRevisionsLicence, + TEST_CWD, +} from './test-helpers.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; +import { FormioApiError } from '../tool-errors.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { registerFormRevertTool } = await import('../tools/form_revert.js'); + +const FORM_ID = '67890abcdef012345678abcd'; + +interface ToolResult { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + structuredContent?: Record; + _meta?: Record; +} + +type FetchOptions = { method?: string; body?: unknown } | undefined; + +function puts() { + return mockFormioFetch.mock.calls.filter(([, , , options]) => (options as FetchOptions)?.method); +} + +async function revert(args: Record, projectUrl?: string) { + const { client } = await createTestClient(registerFormRevertTool, { projectUrl }); + return (await client.callTool({ + name: 'form_revert', + arguments: { cwd: TEST_CWD, ...args }, + })) as ToolResult; +} + +describe('form_revert tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + stubRevisionsLicence(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('takes the form, the revision, a note and the working directory, and no form body', async () => { + const { client } = await createTestClient(registerFormRevertTool); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === 'form_revert'); + expect(tool).toBeDefined(); + expect(Object.keys(tool!.inputSchema.properties ?? {}).sort()).toEqual([ + 'cwd', + 'formId', + 'note', + 'version', + ]); + expect(tool!.inputSchema.required).toEqual( + expect.arrayContaining(['formId', 'version', 'note']) + ); + expect(tool!.annotations?.readOnlyHint).toBe(false); + }); + + it("puts the live form overlaid with the revision's allowlisted fields and the note", async () => { + const revision = { + _vid: 3, + components: [{ type: 'v3' }], + tags: ['t'], + display: 'wizard', + properties: { a: 'b' }, + title: 'NOT RESTORED', + settings: { NOT: 'RESTORED' }, + }; + const live = { _id: FORM_ID, title: 'Live', components: [{ type: 'current' }] }; + mockFormioFetch.mockImplementation( + (path: string, _p: unknown, _c: unknown, options: FetchOptions) => { + if (options?.method) return Promise.resolve({}); + if (path === `form/${FORM_ID}/v/3`) return Promise.resolve(revision); + return Promise.resolve(live); + } + ); + + const result = await revert({ formId: FORM_ID, version: '3', note: 'Reverted to version 3' }); + + expect(result.isError).toBeFalsy(); + expect(puts()).toEqual([ + [ + `form/${FORM_ID}`, + {}, + expect.anything(), + { + method: 'PUT', + body: { + ...live, + components: revision.components, + tags: revision.tags, + display: revision.display, + properties: revision.properties, + _vnote: '@formio/mcp: Reverted to version 3', + }, + }, + ], + ]); + }); + + it('reports an unknown revision as NOT_FOUND and sends no PUT', async () => { + mockFormioFetch.mockImplementation((path: string) => + path === `form/${FORM_ID}/v/99` + ? Promise.reject( + new FormioApiError({ + status: 404, + url: `https://formio.invalid/sub/example/form/${FORM_ID}/v/99`, + body: 'Resource not found', + }) + ) + : Promise.resolve({ _id: FORM_ID }) + ); + + const result = await revert({ formId: FORM_ID, version: '99', note: 'n' }); + + expect(result.isError).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('NOT_FOUND'); + expect(puts()).toEqual([]); + }); + + it.each(['../3', '3/../../x', 'https://example.com/x', '3?x'])( + 'refuses version %j by the path-argument rule before any request', + async (version) => { + const result = await revert({ formId: FORM_ID, version, note: 'n' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/version/); + expect(mockFormioFetch).not.toHaveBeenCalled(); + } + ); + + it('refuses with LICENSE_REQUIRED on a deployment without the revisions licence', async () => { + stubRevisionsLicence(false); + + const result = await revert({ formId: FORM_ID, version: '3', note: 'n' }, freshProjectUrl()); + + expect(result.isError).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('LICENSE_REQUIRED'); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + // The draft is stored as a revision whose _vid is "draft", so GET /v/draft answers + // with it: a revert to it would publish the draft under the wrong tool's name. + it.each(['draft', 'DRAFT'])( + 'refuses version "%s" with INVALID_ARGUMENT naming form_publish', + async (version) => { + const result = await revert({ formId: FORM_ID, version, note: 'n' }); + + expect(result.isError).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('INVALID_ARGUMENT'); + expect(result.content[0].text).toContain('form_publish'); + expect(mockFormioFetch).not.toHaveBeenCalled(); + } + ); + + // Form.io resolves "latest" by sorting on -_vid, and the draft's "draft" sorts above + // every number; a 24-character version is looked up as the revision's own id. Either + // can land on the draft, so the fetched revision is checked, not just the argument. + it.each([ + ['"latest" while a draft exists', 'latest'], + ["the draft revision's own _id", 'abcdef0123456789abcdef01'], + ])( + 'refuses %s with INVALID_ARGUMENT naming form_publish, and sends no PUT', + async (_l, version) => { + mockFormioFetch.mockImplementation((path: string) => + Promise.resolve( + path === `form/${FORM_ID}/v/${version}` + ? { _id: 'abcdef0123456789abcdef01', _vid: 'draft', components: [{ type: 'draft' }] } + : { _id: FORM_ID, components: [] } + ) + ); + + const result = await revert({ formId: FORM_ID, version, note: 'n' }); + + expect(result.isError).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('INVALID_ARGUMENT'); + expect(result.content[0].text).toContain('form_publish'); + expect(puts()).toEqual([]); + } + ); +}); diff --git a/packages/mcp-server/src/__tests__/form_revision_list.test.ts b/packages/mcp-server/src/__tests__/form_revision_list.test.ts new file mode 100644 index 00000000..3a3d03a1 --- /dev/null +++ b/packages/mcp-server/src/__tests__/form_revision_list.test.ts @@ -0,0 +1,111 @@ +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 { registerFormRevisionListTool } = await import('../tools/form_revision_list.js'); + +const COMPACT_SELECT = '_id,_vid,_vnote,_vuser,created,modified'; + +describe('form_revision_list tool', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + mockFormioFetch.mockResolvedValue({ data: [], total: 0 }); + }); + + it('fetches /form/{id}/v newest first with compact metadata and returns the page', async () => { + const id = '67890abcdef012345678abcd'; + const revisions = Array.from({ length: 15 }, (_, index) => ({ _vid: 15 - index })); + mockFormioFetch.mockResolvedValue({ data: revisions, total: 15 }); + const { client } = await createTestClient(registerFormRevisionListTool); + + const result = await client.callTool({ + name: 'form_revision_list', + arguments: { cwd: TEST_CWD, formIdOrPath: id }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith( + `form/${id}/v`, + { _vid__ne: 'draft', limit: '100', skip: '0', sort: '-_vid', select: COMPACT_SELECT }, + TEST_CONFIG, + { withMeta: true } + ); + expect(result.structuredContent).toEqual({ revisions, total: 15, hasMore: false }); + expect(result.content).toEqual([ + { + type: 'text', + text: JSON.stringify({ revisions, total: 15, hasMore: false }, null, 2), + }, + ]); + }); + + it('fetches /{alias}/v for a path alias', async () => { + const { client } = await createTestClient(registerFormRevisionListTool); + + await client.callTool({ + name: 'form_revision_list', + arguments: { cwd: TEST_CWD, formIdOrPath: 'user/login' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith( + 'user/login/v', + expect.objectContaining({ limit: '100', skip: '0' }), + TEST_CONFIG, + { withMeta: true } + ); + }); + + it('uses the given sort and select instead of the defaults', async () => { + const { client } = await createTestClient(registerFormRevisionListTool); + + await client.callTool({ + name: 'form_revision_list', + arguments: { cwd: TEST_CWD, formIdOrPath: 'contact', sort: '_vid', select: '_vid,title' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith( + 'contact/v', + { _vid__ne: 'draft', limit: '100', skip: '0', sort: '_vid', select: '_vid,title' }, + TEST_CONFIG, + { withMeta: true } + ); + }); + + it('reports hasMore when more revisions remain', async () => { + mockFormioFetch.mockResolvedValue({ data: [{ _vid: 15 }, { _vid: 14 }], total: 15 }); + const { client } = await createTestClient(registerFormRevisionListTool); + + const result = await client.callTool({ + name: 'form_revision_list', + arguments: { cwd: TEST_CWD, formIdOrPath: 'contact', limit: 2 }, + }); + + expect(result.structuredContent).toMatchObject({ total: 15, hasMore: true }); + }); + + // Form.io stores a form's draft as a revision with _vid "draft". Excluded in the + // query rather than after it, so the reported total counts the same rows the + // pages hold. + it('excludes the draft revision in the request', async () => { + const { client } = await createTestClient(registerFormRevisionListTool); + + await client.callTool({ + name: 'form_revision_list', + arguments: { cwd: TEST_CWD, formIdOrPath: 'contact' }, + }); + + expect(mockFormioFetch).toHaveBeenCalledWith( + 'contact/v', + expect.objectContaining({ _vid__ne: 'draft' }), + TEST_CONFIG, + { withMeta: true } + ); + }); +}); diff --git a/packages/mcp-server/src/__tests__/form_revisions_list.test.ts b/packages/mcp-server/src/__tests__/form_revisions_list.test.ts deleted file mode 100644 index 9575b15e..00000000 --- a/packages/mcp-server/src/__tests__/form_revisions_list.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 revisions', 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.structuredContent).toEqual({ revisions, count: 1 }); - expect(result.content).toEqual([ - { type: 'text', text: JSON.stringify({ revisions, count: 1 }, 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 d9e0d0e6..657a2a2f 100644 --- a/packages/mcp-server/src/__tests__/form_update.test.ts +++ b/packages/mcp-server/src/__tests__/form_update.test.ts @@ -1,5 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createTestClient, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createTestClient, + freshProjectUrl, + stubRevisionsLicence, + TEST_CONFIG, + TEST_CWD, +} from './test-helpers.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; const mockFormioFetch = vi.fn(); vi.mock('../formio-client.js', async (importOriginal) => { @@ -10,24 +17,16 @@ 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', () => { beforeEach(() => { mockFormioFetch.mockReset(); + stubRevisionsLicence(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); it('is listed in available tools with workflow guidance', async () => { @@ -122,43 +121,16 @@ describe('form_update tool', () => { ]); }); - 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 () => { + it('has no publish, revert or version argument: those are form_publish and form_revert', 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(); + const { tools } = await client.listTools(); + const properties = Object.keys( + tools.find((t) => t.name === 'form_update')!.inputSchema.properties ?? {} + ); + expect(properties).not.toContain('publish'); + expect(properties).not.toContain('revert'); + expect(properties).not.toContain('version'); + expect(properties).toEqual(expect.arrayContaining(['cwd', 'formId', 'form', 'note', 'draft'])); }); it('draft merges caller form over existing draft and stamps _vnote', async () => { @@ -193,127 +165,240 @@ describe('form_update tool', () => { }); }); - it('draft rejects bodies with non-allowlisted fields', async () => { + // An agent edits what form_get returned and saves it back as a draft: the + // server-owned fields ride along, and the draft takes only what a draft holds. + // The stored draft carries the form's other fields as they stood when it was saved. + const STORED_DRAFT = { + _id: 'abcdef0123456789abcdef01', + _rid: '67890abcdef012345678abcd', + _vid: 'draft', + title: 'Contact', + name: 'contact', + path: 'contact', + type: 'form', + access: [{ type: 'read_all', roles: [] }], + submissionAccess: [], + revisions: 'original', + components: [{ type: 'old' }], + }; + + const FROM_FORM_GET = { + _id: '67890abcdef012345678abcd', + _vid: 4, + title: 'Contact', + name: 'contact', + path: 'contact', + type: 'form', + created: '2026-01-01T00:00:00.000Z', + modified: '2026-01-02T00:00:00.000Z', + owner: 'abcdef012345678901234567', + project: 'fedcba9876543210fedcba98', + machineName: 'example:contact', + access: [{ type: 'read_all', roles: [] }], + submissionAccess: [], + revisions: 'original', + components: [{ type: 'phoneNumber', key: 'phone' }], + settings: { theme: 'dark' }, + tags: ['common'], + display: 'form', + }; + + it("draft saves only the allowlisted fields of form_get's output", async () => { + const formId = '67890abcdef012345678abcd'; + mockFormioFetch.mockResolvedValue(STORED_DRAFT); + const { client } = await createTestClient(registerFormUpdateTool); + + const result = await client.callTool({ + name: 'form_update', + arguments: { cwd: TEST_CWD, formId, form: FROM_FORM_GET, draft: true, note: 'Add phone' }, + }); + + expect(result.isError).toBeFalsy(); + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}/draft`, {}, TEST_CONFIG, { + method: 'PUT', + body: { + ...STORED_DRAFT, + components: FROM_FORM_GET.components, + settings: FROM_FORM_GET.settings, + tags: FROM_FORM_GET.tags, + display: FROM_FORM_GET.display, + _vnote: '@formio/mcp: Add phone', + }, + }); + }); + + // A draft saves only the draft fields, so a changed title or path in the body would + // be dropped without a word. It is refused instead, naming the field, before any write. + it.each([ + ['title', { title: 'Contact Us' }], + ['path', { path: 'contact-us' }], + ])( + 'draft refuses a changed %s with INVALID_ARGUMENT and writes nothing', + async (field, change) => { + const formId = '67890abcdef012345678abcd'; + mockFormioFetch.mockResolvedValue(STORED_DRAFT); + const { client } = await createTestClient(registerFormUpdateTool); + + const result = (await client.callTool({ + name: 'form_update', + arguments: { + cwd: TEST_CWD, + formId, + form: { ...FROM_FORM_GET, ...change }, + draft: true, + note: 'n', + }, + })) as { + isError?: boolean; + content: Array<{ text?: string }>; + _meta?: Record; + }; + + expect(result.isError).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('INVALID_ARGUMENT'); + expect(result.content[0].text).toContain(field); + expect(result.content[0].text).toMatch(/form_update without `?draft/); + expect( + mockFormioFetch.mock.calls.filter( + ([, , , options]) => (options as { method?: string })?.method + ) + ).toEqual([]); + } + ); + + // The draft keeps the title it had when it was saved; a rename since then is on the + // live form, which is what form_get returns. Echoing that back is not a change. + it("draft accepts form_get's output after the live form was renamed since the draft", async () => { + const formId = '67890abcdef012345678abcd'; + const live = { ...FROM_FORM_GET, title: 'Contact Us', path: 'contact-us' }; + mockFormioFetch.mockImplementation( + (path: string, _q: unknown, _c: unknown, options?: { method?: string }) => + Promise.resolve(options?.method ? {} : path === `form/${formId}` ? live : STORED_DRAFT) + ); + const { client } = await createTestClient(registerFormUpdateTool); + + const result = await client.callTool({ + name: 'form_update', + arguments: { cwd: TEST_CWD, formId, form: live, draft: true, note: 'n' }, + }); + + expect(result.isError).toBeFalsy(); + expect(mockFormioFetch).toHaveBeenCalledWith( + `form/${formId}/draft`, + {}, + TEST_CONFIG, + expect.objectContaining({ method: 'PUT' }) + ); + }); + + it('draft without any field a draft holds writes nothing', async () => { const { client } = await createTestClient(registerFormUpdateTool); const result = await client.callTool({ name: 'form_update', arguments: { cwd: TEST_CWD, formId: '67890abcdef012345678abcd', - form: { components: [], title: 'X' }, + form: { title: 'Renamed' }, 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); + it('draft refuses with LICENSE_REQUIRED on a deployment without the revisions licence', async () => { + stubRevisionsLicence(false); + const { client } = await createTestClient(registerFormUpdateTool, { + projectUrl: freshProjectUrl(), + }); const result = await client.callTool({ name: 'form_update', arguments: { cwd: TEST_CWD, formId: '67890abcdef012345678abcd', form: { components: [] }, - publish: true, + draft: true, note: 'n', }, }); expect(result.isError).toBe(true); - expect(result.content).toEqual([ - expect.objectContaining({ text: expect.stringMatching(/No draft exists/) }), - ]); + expect((result._meta as Record)[ERROR_META_KEY]?.code).toBe( + 'LICENSE_REQUIRED' + ); + expect(mockFormioFetch).not.toHaveBeenCalled(); }); - it('publish ignores caller form, overlays draft allowlist on live, stamps _vnote', async () => { + // The stored form is read only once the licence says the history check applies. Read + // beside the probe, it needs a token, so on an unlicensed deployment a refused call + // could still start a browser login in the background for a read nobody uses. + it('does not read the stored form when the licence refuses the write', async () => { + stubRevisionsLicence(false); 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({}); - } - ); + mockFormioFetch.mockResolvedValue({ _id: formId, name: 'demo', revisions: '' }); + const { client } = await createTestClient(registerFormUpdateTool, { + projectUrl: freshProjectUrl(), + }); - const { client } = await createTestClient(registerFormUpdateTool); - await client.callTool({ + const result = (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', - }, - }); + arguments: { cwd: TEST_CWD, formId, form: { components: [] }, note: 'n' }, + })) as { isError?: boolean }; - expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, TEST_CONFIG, { - method: 'PUT', - body: { - ...live, - components: [{ type: 'staged' }], - _vnote: '@formio/mcp: ship it', - }, + expect(result.isError).toBe(true); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + // An unlicensed deployment answers with its own refusal, without waiting on a read + // that cannot change it. + it('refuses as unlicensed without waiting for the stored form', async () => { + stubRevisionsLicence(false); + const formId = '67890abcdef012345678abcd'; + mockFormioFetch.mockReturnValue(new Promise(() => undefined)); + const { client } = await createTestClient(registerFormUpdateTool, { + projectUrl: freshProjectUrl(), }); + + const result = (await client.callTool({ + name: 'form_update', + arguments: { cwd: TEST_CWD, formId, form: { components: [] }, note: 'n' }, + })) as { isError?: boolean; content: Array<{ text?: string }> }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/licence does not include form revisions/); }); - it('revert PUTs live overlaid with revision revert allowlist and stamps _vnote', async () => { + // Unknown is treated as licensed for the history checks, never as unlicensed: the + // body is not stripped and the save is not refused on the licence's account, but a + // stored form with history off still needs the caller's decision. + it('checks the stored form and sends the body as written when the licence is unknown', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))); 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({}); - } + mockFormioFetch.mockResolvedValue({ _id: formId, name: 'demo', revisions: 'original' }); + const { client } = await createTestClient(registerFormUpdateTool, { + projectUrl: freshProjectUrl(), + }); + + const form = { title: 'Updated', components: [], revisions: 'current' }; + const result = await client.callTool({ + name: 'form_update', + arguments: { cwd: TEST_CWD, formId, form: { title: 'Updated', components: [] }, note: 'n' }, + }); + expect(result.isError ?? false).toBe(false); + expect(mockFormioFetch).toHaveBeenCalledWith( + `form/${formId}`, + { select: 'revisions,name' }, + expect.anything() ); - const { client } = await createTestClient(registerFormUpdateTool); + mockFormioFetch.mockClear(); 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', - }, + arguments: { cwd: TEST_CWD, formId, form, note: 'n' }, }); - - expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, TEST_CONFIG, { + expect(mockFormioFetch).toHaveBeenCalledWith(`form/${formId}`, {}, expect.anything(), { method: 'PUT', - body: { - ...live, - components: [{ type: 'v3' }], - tags: ['t'], - display: 'wizard', - _vnote: '@formio/mcp: Reverted to version 3', - }, + body: { ...form, _vnote: '@formio/mcp: n' }, }); }); }); diff --git a/packages/mcp-server/src/__tests__/formio-client-errors.test.ts b/packages/mcp-server/src/__tests__/formio-client-errors.test.ts new file mode 100644 index 00000000..458de02f --- /dev/null +++ b/packages/mcp-server/src/__tests__/formio-client-errors.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { formioFetch } from '../formio-client.js'; +import { ResolvedFormioConfig } from '../config.js'; +import { FormioApiError, FormioNetworkError, FORMIO_BODY_LIMIT } from '../tool-errors.js'; +import { TEST_CONFIG as config, TEST_PROJECT_URL } from './test-helpers.js'; + +vi.mock('../ensure-auth.js', () => ({ + ensureAuthenticated: vi.fn(), + resetAuthState: vi.fn(), + invalidateJwtCache: vi.fn(), +})); + +vi.mock('../token-cache.js', () => ({ + readToken: vi.fn(), + saveToken: vi.fn(), + clearToken: vi.fn(), +})); + +import { ensureAuthenticated } from '../ensure-auth.js'; + +const mockEnsureAuth = vi.mocked(ensureAuthenticated); + +function errorResponse(status: number, body: string, headers: Record = {}) { + return new Response(body, { status, headers }); +} + +// The shape undici gives a request that never got a response: a TypeError whose +// cause carries the system error code. +function fetchFailure(code: string, detail: string): TypeError { + const cause = Object.assign(new Error(`${detail}`), { code }); + return new TypeError('fetch failed', { cause }); +} + +async function caught(promise: Promise): Promise { + return promise.then( + () => { + throw new Error('expected the request to fail'); + }, + (error: unknown) => error + ); +} + +describe('formioFetch raises a FormioApiError for an error response', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockEnsureAuth.mockReset(); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('carries the status, the URL and the body text', async () => { + const body = JSON.stringify({ status: 400, message: 'Form validation failed' }); + mockFetch.mockResolvedValue(errorResponse(400, body)); + + const error = await caught(formioFetch('form', {}, config, { method: 'POST', body: {} })); + + expect(error).toBeInstanceOf(FormioApiError); + const apiError = error as FormioApiError; + expect(apiError.status).toBe(400); + expect(apiError.url).toBe(`${TEST_PROJECT_URL}/form`); + expect(apiError.body).toBe(body); + }); + + it('keeps the existing status and URL prose', async () => { + mockFetch.mockResolvedValue(errorResponse(404, '')); + + const error = (await caught(formioFetch('form/abc', {}, config))) as Error; + + expect(error.message).toContain('Form.io API error: 404'); + expect(error.message).toContain(`URL: ${TEST_PROJECT_URL}/form/abc`); + }); + + it(`truncates the body to ${FORMIO_BODY_LIMIT} characters`, async () => { + mockFetch.mockResolvedValue(errorResponse(500, 'x'.repeat(FORMIO_BODY_LIMIT + 500))); + + const error = (await caught(formioFetch('form', {}, config))) as FormioApiError; + + expect(error.body).toHaveLength(FORMIO_BODY_LIMIT); + }); + + it("includes Form.io's JSON message in the prose", async () => { + const body = JSON.stringify({ + status: 400, + message: 'form validation failed: path: The Path must be unique per Project.', + errors: { + path: { + path: 'path', + name: 'ValidatorError', + message: 'The Path must be unique per Project.', + }, + }, + }); + mockFetch.mockResolvedValue(errorResponse(400, body)); + + const error = (await caught(formioFetch('form', {}, config))) as Error; + + expect(error.message).toContain('The Path must be unique per Project.'); + }); + + it("falls back to Form.io's JSON name when there is no message", async () => { + mockFetch.mockResolvedValue(errorResponse(400, JSON.stringify({ name: 'ValidationError' }))); + + const error = (await caught(formioFetch('form', {}, config))) as Error; + + expect(error.message).toContain('ValidationError'); + }); + + // resourcejs answers a missing document with `errors` as an array of strings. + it('includes an errors array of strings in the prose', async () => { + mockFetch.mockResolvedValue( + errorResponse(404, JSON.stringify({ status: 404, errors: ['Resource not found'] })) + ); + + const error = (await caught(formioFetch('form', {}, config))) as Error; + + expect(error.message).toContain('Resource not found'); + }); + + it('includes an errors array of objects carrying a message in the prose', async () => { + mockFetch.mockResolvedValue( + errorResponse( + 400, + JSON.stringify({ errors: [{ message: 'First problem' }, { message: 'Second problem' }] }) + ) + ); + + const error = (await caught(formioFetch('form', {}, config))) as Error; + + expect(error.message).toContain('First problem Second problem'); + }); + + it('includes a short plain-text body in the prose', async () => { + mockFetch.mockResolvedValue(errorResponse(400, 'Invalid alias')); + + const error = (await caught(formioFetch('contact/v', {}, config))) as Error; + + expect(error.message).toContain('Invalid alias'); + }); + + it('still names where a redirect pointed', async () => { + mockFetch.mockResolvedValue(errorResponse(302, '', { location: 'https://other.example/' })); + + const error = (await caught(formioFetch('form', {}, config))) as FormioApiError; + + expect(error).toBeInstanceOf(FormioApiError); + expect(error.status).toBe(302); + expect(error.message).toMatch(/302[\s\S]*https:\/\/other\.example\//); + }); + + it('raises the retry response after a 401 re-authentication', async () => { + const jwtConfig: ResolvedFormioConfig = { ...config, apiKey: undefined, jwt: 'expired' }; + mockEnsureAuth.mockImplementation(async () => { + jwtConfig.jwt = 'still-bad'; + }); + mockFetch + .mockResolvedValueOnce(errorResponse(401, 'Unauthorized')) + .mockResolvedValueOnce(errorResponse(401, 'Token expired')); + + const error = (await caught(formioFetch('form', {}, jwtConfig))) as FormioApiError; + + expect(error).toBeInstanceOf(FormioApiError); + expect(error.status).toBe(401); + expect(error.body).toBe('Token expired'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +describe('formioFetch raises a FormioNetworkError when no response arrives', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockEnsureAuth.mockReset(); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + ['ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:443'], + ['ENOTFOUND', 'getaddrinfo ENOTFOUND formio.invalid'], + ['CERT_HAS_EXPIRED', 'certificate has expired'], + ])('names the %s cause and the URL', async (code, detail) => { + mockFetch.mockRejectedValue(fetchFailure(code, detail)); + + const error = (await caught(formioFetch('form', {}, config))) as FormioNetworkError; + + expect(error).toBeInstanceOf(FormioNetworkError); + expect(error.url).toBe(`${TEST_PROJECT_URL}/form`); + expect(error.message).toContain(code); + expect(error.message).toContain(`${TEST_PROJECT_URL}/form`); + expect(error.cause).toBeInstanceOf(TypeError); + }); + + it('names a code carried by an aggregate cause', async () => { + const inner = Object.assign(new Error('connect ECONNREFUSED ::1:443'), { + code: 'ECONNREFUSED', + }); + const cause = new AggregateError([inner], ''); + mockFetch.mockRejectedValue(new TypeError('fetch failed', { cause })); + + const error = (await caught(formioFetch('form', {}, config))) as FormioNetworkError; + + expect(error.message).toContain('ECONNREFUSED'); + }); +}); diff --git a/packages/mcp-server/src/__tests__/formio-client-page.test.ts b/packages/mcp-server/src/__tests__/formio-client-page.test.ts new file mode 100644 index 00000000..e1cbde94 --- /dev/null +++ b/packages/mcp-server/src/__tests__/formio-client-page.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { formioFetch } from '../formio-client.js'; +import { FormioApiError } from '../tool-errors.js'; +import { TEST_CONFIG as config } from './test-helpers.js'; + +vi.mock('../ensure-auth.js', () => ({ + ensureAuthenticated: vi.fn(), + resetAuthState: vi.fn(), + invalidateJwtCache: vi.fn(), +})); + +vi.mock('../token-cache.js', () => ({ + readToken: vi.fn(), + saveToken: vi.fn(), + clearToken: vi.fn(), +})); + +// Form.io index routes report the whole collection's size in Content-Range, as +// `{from}-{to}/{total}`, or `*/{total}` when the page is empty. A skip at or past +// the total is answered with 416 and `*/{total}`. +function indexResponse(status: number, items: unknown[], contentRange?: string): Response { + return new Response(JSON.stringify(items), { + status, + headers: contentRange ? { 'Content-Range': contentRange } : {}, + }); +} + +describe('formioFetch with withMeta', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns the items and the total read from a range Content-Range', async () => { + const items = [{ _id: 'a' }, { _id: 'b' }]; + mockFetch.mockResolvedValue(indexResponse(206, items, '0-9/42')); + + const page = await formioFetch('role', {}, config, { withMeta: true }); + + expect(page).toEqual({ data: items, total: 42 }); + }); + + it('reads the total from an empty Content-Range', async () => { + mockFetch.mockResolvedValue(indexResponse(200, [], '*/0')); + + const page = await formioFetch('role', {}, config, { withMeta: true }); + + expect(page).toEqual({ data: [], total: 0 }); + }); + + it('answers a 416 past the end as an empty page carrying the total', async () => { + mockFetch.mockResolvedValue(indexResponse(416, [], '*/3')); + + const page = await formioFetch('role', { skip: '500' }, config, { withMeta: true }); + + expect(page).toEqual({ data: [], total: 3 }); + }); + + it('still raises a 416 that carries no total', async () => { + mockFetch.mockResolvedValue(indexResponse(416, [])); + + await expect(formioFetch('role', {}, config, { withMeta: true })).rejects.toBeInstanceOf( + FormioApiError + ); + }); + + it('still raises any other error status', async () => { + mockFetch.mockResolvedValue(indexResponse(404, [], '*/3')); + + await expect(formioFetch('role', {}, config, { withMeta: true })).rejects.toMatchObject({ + status: 404, + }); + }); + + it('leaves the total undefined when the response reports none', async () => { + const items = [{ _id: 'a' }]; + mockFetch.mockResolvedValue(indexResponse(200, items)); + + const page = await formioFetch('role', {}, config, { withMeta: true }); + + expect(page).toEqual({ data: items, total: undefined }); + }); + + it('leaves the total undefined when Form.io reports it as unknown', async () => { + mockFetch.mockResolvedValue(indexResponse(200, [], '0-9/*')); + + const page = await formioFetch('role', {}, config, { withMeta: true }); + + expect(page).toEqual({ data: [], total: undefined }); + }); + + it('returns the body alone without withMeta', async () => { + const items = [{ _id: 'a' }]; + mockFetch.mockResolvedValue(indexResponse(200, items, '0-0/1')); + + expect(await formioFetch('role', {}, config)).toEqual(items); + }); +}); diff --git a/packages/mcp-server/src/__tests__/hello.test.ts b/packages/mcp-server/src/__tests__/hello.test.ts deleted file mode 100644 index 2962368c..00000000 --- a/packages/mcp-server/src/__tests__/hello.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { registerHelloTool } from '../tools/hello.js'; - -async function createTestClient() { - const server = new McpServer({ name: 'test', version: '0.0.0' }); - registerHelloTool(server); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - - const client = new Client({ name: 'test-client', version: '0.0.0' }); - await client.connect(clientTransport); - - return { client, server }; -} - -describe('hello tool', () => { - it('greets the world by default', async () => { - const { client } = await createTestClient(); - const result = await client.callTool({ name: 'hello', arguments: {} }); - expect(result.content).toEqual([{ type: 'text', text: 'Hello from formio-mcp, world!' }]); - }); - - it('greets a specific name', async () => { - const { client } = await createTestClient(); - const result = await client.callTool({ name: 'hello', arguments: { name: 'Form.io' } }); - expect(result.content).toEqual([{ type: 'text', text: 'Hello from formio-mcp, Form.io!' }]); - }); - - it('is listed in available tools', async () => { - const { client } = await createTestClient(); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name)).toContain('hello'); - }); -}); diff --git a/packages/mcp-server/src/__tests__/insecure-tls.test.ts b/packages/mcp-server/src/__tests__/insecure-tls.test.ts deleted file mode 100644 index ac83798c..00000000 --- a/packages/mcp-server/src/__tests__/insecure-tls.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isInsecureTlsEnabled } from '../formio-client.js'; - -describe('isInsecureTlsEnabled', () => { - it('returns true for "true"', () => { - expect(isInsecureTlsEnabled('true')).toBe(true); - }); - - it('returns true for "1"', () => { - expect(isInsecureTlsEnabled('1')).toBe(true); - }); - - it('returns true for mixed/upper case "TRUE"', () => { - expect(isInsecureTlsEnabled('TRUE')).toBe(true); - expect(isInsecureTlsEnabled('True')).toBe(true); - }); - - it('trims surrounding whitespace', () => { - expect(isInsecureTlsEnabled(' true ')).toBe(true); - expect(isInsecureTlsEnabled(' 1 ')).toBe(true); - }); - - it('returns false for falsy/other values', () => { - expect(isInsecureTlsEnabled(undefined)).toBe(false); - expect(isInsecureTlsEnabled('')).toBe(false); - expect(isInsecureTlsEnabled('0')).toBe(false); - expect(isInsecureTlsEnabled('false')).toBe(false); - expect(isInsecureTlsEnabled('yes')).toBe(false); - }); -}); diff --git a/packages/mcp-server/src/__tests__/list-contract.test.ts b/packages/mcp-server/src/__tests__/list-contract.test.ts new file mode 100644 index 00000000..b03562d1 --- /dev/null +++ b/packages/mcp-server/src/__tests__/list-contract.test.ts @@ -0,0 +1,118 @@ +/** + * The list contract every list tool shares: `limit` / `skip` / `sort` / `select` + * in, the items plus `total` and `hasMore` out, and no `count`. + */ + +import { describe, expect, it } from 'vitest'; +import { registerAllTools } from '../tools/index.js'; +import { listResult, pageOf } from '../tools/list-contract.js'; +import { connectTools } from './test-helpers.js'; + +const PAGED = { + form_list: 'forms', + role_list: 'roles', + action_list: 'actions', + form_revision_list: 'revisions', +} as const; + +interface JsonSchemaObject { + properties?: Record; + required?: string[]; +} + +async function listedTools() { + const client = await connectTools((server) => registerAllTools(server, {})); + const { tools } = await client.listTools(); + return new Map(tools.map((tool) => [tool.name, tool])); +} + +describe('the list contract', () => { + it.each(Object.keys(PAGED))( + '%s takes limit (default 100), skip (default 0), sort and select', + async (name) => { + const tool = (await listedTools()).get(name); + const input = tool?.inputSchema as JsonSchemaObject; + expect(input.properties?.limit?.default).toBe(100); + expect(input.properties?.skip?.default).toBe(0); + expect(input.properties).toHaveProperty('sort'); + expect(input.properties).toHaveProperty('select'); + } + ); + + it.each([...Object.entries(PAGED), ['action_type_list', 'actionTypes']])( + '%s returns %s with total and hasMore, and no count', + async (name, key) => { + const tool = (await listedTools()).get(name); + const output = tool?.outputSchema as JsonSchemaObject; + expect(output.required).toEqual(expect.arrayContaining([key, 'hasMore'])); + expect(output.properties).toHaveProperty('total'); + expect(output.properties).not.toHaveProperty('count'); + } + ); + + // Form.io does not always report a Content-Range total. A count of the items seen + // would read as the size of the collection, so the field is left out instead. + it.each([...Object.keys(PAGED), 'action_type_list'])( + '%s declares total optional, saying it is absent when Form.io reports none', + async (name) => { + const tool = (await listedTools()).get(name); + const output = tool?.outputSchema as JsonSchemaObject & { + properties?: Record; + }; + expect(output.required ?? []).not.toContain('total'); + expect(output.properties?.total?.description).toMatch(/absent when Form\.io/i); + } + ); + + describe('pageOf', () => { + it('has more while skip plus the page is short of the total', () => { + expect(pageOf({ items: [1, 2], total: 5, skip: 0, limit: 2 })).toEqual({ + items: [1, 2], + total: 5, + hasMore: true, + }); + expect(pageOf({ items: [4, 5], total: 5, skip: 3, limit: 2 })).toMatchObject({ + hasMore: false, + }); + }); + + it('has no more past the end', () => { + expect(pageOf({ items: [], total: 3, skip: 500, limit: 100 })).toEqual({ + items: [], + total: 3, + hasMore: false, + }); + }); + + it('leaves total out when Form.io reports none', () => { + const page = pageOf({ items: [1, 2], total: undefined, skip: 10, limit: 5 }); + expect(page).toEqual({ items: [1, 2], hasMore: false }); + expect(page).not.toHaveProperty('total'); + }); + + // A full page with no reported total says nothing about what follows it, so the + // caller is told to ask for the next one rather than that the list has ended. + it('has more when Form.io reports no total and the page is full', () => { + expect(pageOf({ items: [1, 2], total: undefined, skip: 10, limit: 2 })).toEqual({ + items: [1, 2], + hasMore: true, + }); + }); + }); + + describe('listResult', () => { + it('carries total when the page has one', () => { + expect(listResult('roles', { items: [1], total: 1, hasMore: false })).toEqual({ + roles: [1], + total: 1, + hasMore: false, + }); + }); + + it('leaves total out when the page has none', () => { + const result = listResult('roles', { items: [1], hasMore: true }); + expect(result).toEqual({ roles: [1], hasMore: true }); + expect(result).not.toHaveProperty('total'); + }); + }); +}); diff --git a/packages/mcp-server/src/__tests__/local-server-edges.test.ts b/packages/mcp-server/src/__tests__/local-server-edges.test.ts index 80fb00c2..c902cca8 100644 --- a/packages/mcp-server/src/__tests__/local-server-edges.test.ts +++ b/packages/mcp-server/src/__tests__/local-server-edges.test.ts @@ -1,5 +1,4 @@ -// The two short-lived local servers — the portal login and the revisions consent -// page — at the edges Express 5 changed. +// The short-lived local login server at the edges Express 5 changed. // // Express 5 no longer defaults `req.body` to `{}` when no body parser matched, so // a POST without a JSON content type reaches the handler with `req.body` @@ -12,7 +11,6 @@ import net from 'node:net'; import { describe, it, expect, vi } from 'vitest'; import { authenticate } from '../auth.js'; import { ResolvedFormioConfig } from '../config.js'; -import { requestRevisionsLicenseConsent } from '../revisions/browser-prompts.js'; vi.mock('child_process', () => ({ execFile: vi.fn() })); @@ -68,19 +66,3 @@ describe('a login port that is already taken', () => { } }); }); - -describe('the revisions consent callback', () => { - it('treats a POST with no JSON body as no choice rather than failing the request', async () => { - // The choice resolves as the response is sent, before the client has read - // it, so hold on to the request and await it separately. - let posted: Promise | undefined; - const choice = await requestRevisionsLicenseConsent('a deployment', 'an action', { - openBrowser: false, - onReady: (port) => { - posted = postText(port, 'not json'); - }, - }); - expect((await posted)?.status).toBe(200); - expect(typeof choice).toBe('string'); - }); -}); diff --git a/packages/mcp-server/src/__tests__/mcp-error-contract.test.ts b/packages/mcp-server/src/__tests__/mcp-error-contract.test.ts new file mode 100644 index 00000000..052b9289 --- /dev/null +++ b/packages/mcp-server/src/__tests__/mcp-error-contract.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest'; +import { ERROR_META_KEY, toMcpError } from '../mcp-responses.js'; +import { formioFetch } from '../formio-client.js'; +import { + FormioApiError, + FormioNetworkError, + ToolError, + TOOL_ERROR_CODES, + ToolErrorCode, +} from '../tool-errors.js'; +import { ProjectNotConfiguredError, requireBaseUrl, resolveProject } from '../project-resolver.js'; +import { ProjectMapUnreadableError } from '../project-map.js'; +import { CommittedConfigUnusableError } from '../committed-config.js'; +import { TEST_CONFIG, TEST_PROJECT_URL } from './test-helpers.js'; + +const URL_UNDER_TEST = `${TEST_PROJECT_URL}/form`; + +function raised(fn: () => unknown): unknown { + try { + fn(); + } catch (error) { + return error; + } + throw new Error('expected a throw'); +} + +function apiError(status: number, body?: string, location?: string): FormioApiError { + return new FormioApiError({ status, url: URL_UNDER_TEST, body, location }); +} + +function structured(error: unknown) { + return toMcpError(error)._meta[ERROR_META_KEY]; +} + +function text(result: ReturnType): string { + return result.content.map((part) => part.text).join('\n'); +} + +describe('toMcpError returns a structured code beside the message', () => { + it('leads the unchanged prose message with the code', () => { + const result = toMcpError(new Error('something specific')); + expect(result.isError).toBe(true); + expect(text(result)).toBe('[INTERNAL] something specific'); + }); + + it('puts the code ahead of the notes, and the notes ahead of the message', () => { + const result = toMcpError(new ProjectNotConfiguredError('the failure'), [ + 'a note that explains it', + ]); + expect(text(result)).toBe('[NOT_CONFIGURED] a note that explains it\nthe failure'); + }); + + // MCP clients validate structuredContent against the tool's success outputSchema + // even on an error result, so the error payload travels in _meta instead. + it('carries no structuredContent', () => { + expect(toMcpError(new Error('x'))).not.toHaveProperty('structuredContent'); + }); + + it('maps an error of no known class to INTERNAL with no status or body', () => { + expect(structured(new Error('boom'))).toEqual({ code: 'INTERNAL' }); + expect(structured('a thrown string')).toEqual({ code: 'INTERNAL' }); + }); + + it('passes a ToolError code through', () => { + const codes: ToolErrorCode[] = ['INVALID_ARGUMENT', 'LICENSE_REQUIRED', 'NO_DRAFT']; + for (const code of codes) { + expect(structured(new ToolError({ code, message: 'x' }))).toEqual({ code }); + } + }); + + it('publishes the fixed code set', () => { + expect([...TOOL_ERROR_CODES].sort()).toEqual( + [ + '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', + 'INTERNAL', + ].sort() + ); + }); +}); + +describe('toMcpError maps the resolution errors', () => { + it('maps ProjectNotConfiguredError to NOT_CONFIGURED', () => { + expect(structured(new ProjectNotConfiguredError('nothing here'))).toEqual({ + code: 'NOT_CONFIGURED', + }); + }); + + it('maps an unresolved Base URL to BASE_URL_UNRESOLVED', () => { + const error = raised(() => + requireBaseUrl({ projectUrl: 'https://myproject.mysite.com', cwd: '/workspace/x' }) + ); + expect(structured(error)).toEqual({ code: 'BASE_URL_UNRESOLVED' }); + }); + + it('maps ProjectMapUnreadableError to CONFIG_UNREADABLE', () => { + const error = new ProjectMapUnreadableError('/home/.formio/projects.json', new Error('bad')); + expect(structured(error)).toEqual({ code: 'CONFIG_UNREADABLE' }); + }); + + it('maps CommittedConfigUnusableError to CONFIG_UNREADABLE', () => { + const error = new CommittedConfigUnusableError('/repo/formio.json', 'not JSON'); + expect(structured(error)).toEqual({ code: 'CONFIG_UNREADABLE' }); + }); + + it('maps a relative cwd to INVALID_ARGUMENT', () => { + const error = raised(() => resolveProject('relative/dir', {})); + expect(structured(error)).toEqual({ code: 'INVALID_ARGUMENT' }); + }); + + it('maps a request path outside the project to INVALID_ARGUMENT', async () => { + const error = await formioFetch('../elsewhere/form', {}, TEST_CONFIG).catch( + (failure: unknown) => failure + ); + expect(structured(error)).toEqual({ code: 'INVALID_ARGUMENT' }); + }); +}); + +describe('toMcpError maps Form.io responses', () => { + it.each<[number, string | undefined, ToolErrorCode]>([ + [401, 'Unauthorized', 'AUTH_REQUIRED'], + [403, 'Forbidden', 'FORBIDDEN'], + [404, 'Not Found', 'NOT_FOUND'], + [400, 'Invalid alias', 'NOT_FOUND'], + [400, '{"message":"Form validation failed"}', 'VALIDATION_FAILED'], + [422, '{"message":"Unprocessable"}', 'VALIDATION_FAILED'], + [301, undefined, 'REDIRECTED'], + [302, undefined, 'REDIRECTED'], + [500, 'Internal Server Error', 'UPSTREAM_ERROR'], + [503, 'Service Unavailable', 'UPSTREAM_ERROR'], + ])('maps %i (%s) to %s', (status, body, code) => { + expect(structured(apiError(status, body))).toEqual({ + code, + status, + ...(body === undefined ? {} : { body }), + }); + }); + + it('omits an empty body', () => { + expect(structured(apiError(404, ''))).toEqual({ code: 'NOT_FOUND', status: 404 }); + }); + + it('maps a request with no response to NETWORK_ERROR, naming the cause and URL', () => { + const cause = new TypeError('fetch failed', { + cause: Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }), + }); + const result = toMcpError(new FormioNetworkError({ url: URL_UNDER_TEST, cause })); + expect(result._meta[ERROR_META_KEY]).toEqual({ code: 'NETWORK_ERROR' }); + expect(text(result).startsWith('[NETWORK_ERROR] ')).toBe(true); + expect(text(result)).toContain('ECONNREFUSED'); + expect(text(result)).toContain(URL_UNDER_TEST); + }); +}); diff --git a/packages/mcp-server/src/__tests__/mcpb-build.test.ts b/packages/mcp-server/src/__tests__/mcpb-build.test.ts index e07d3df7..f680aa9c 100644 --- a/packages/mcp-server/src/__tests__/mcpb-build.test.ts +++ b/packages/mcp-server/src/__tests__/mcpb-build.test.ts @@ -137,7 +137,7 @@ describe('pnpm build:mcpb', () => { const request = [ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcpb-test","version":"1"}}}', '{"jsonrpc":"2.0","method":"notifications/initialized"}', - '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hello","arguments":{"name":"MCPB"}}}', + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"server_status","arguments":{}}}', '', ].join('\n'); @@ -149,7 +149,10 @@ describe('pnpm build:mcpb', () => { }); expect(result.stdout).toContain('"serverInfo"'); - expect(result.stdout).toContain('Hello from formio-mcp, MCPB!'); + // server_status needs no project and makes no Form.io request, so it answers + // from the bundle alone, reporting how the environment's project resolves. + expect(result.stdout).toMatch(/"text":"formio-mcp \d+\.\d+\.\d+/); + expect(result.stdout).toContain('"status":"ok"'); }, 90_000); // Directories that ingest the bundle read this list instead of launching the diff --git a/packages/mcp-server/src/__tests__/output-schemas-open.test.ts b/packages/mcp-server/src/__tests__/output-schemas-open.test.ts new file mode 100644 index 00000000..80e39b99 --- /dev/null +++ b/packages/mcp-server/src/__tests__/output-schemas-open.test.ts @@ -0,0 +1,50 @@ +/** + * Every published output schema is open at every level. + * + * A client that validates structuredContent against the published schema rejects + * any property a closed object does not list, and Form.io documents carry fields no + * schema here enumerates (`_vid`, `pdfComponents`, `controller`, `esign`, …). A + * closed object anywhere in a tool's schema turns a successful call into a + * validation failure the day such a field appears. + */ + +import { describe, expect, it } from 'vitest'; +import { registerAllTools } from '../tools/index.js'; +import { connectTools } from './test-helpers.js'; + +interface ClosedObject { + tool: string; + path: string; +} + +function closedObjects(node: unknown, tool: string, path: string): ClosedObject[] { + if (Array.isArray(node)) { + return node.flatMap((item, index) => closedObjects(item, tool, `${path}[${index}]`)); + } + if (typeof node !== 'object' || node === null) { + return []; + } + const record = node as Record; + const here = record.additionalProperties === false ? [{ tool, path }] : []; + return [ + ...here, + ...Object.entries(record).flatMap(([key, value]) => + closedObjects(value, tool, `${path}.${key}`) + ), + ]; +} + +describe('published output schemas', () => { + it('leave additional properties open at every level of every tool', async () => { + const client = await connectTools((server) => registerAllTools(server, {})); + const { tools } = await client.listTools(); + + const withSchemas = tools.filter((tool) => tool.outputSchema !== undefined); + expect(withSchemas.length).toBe(tools.length); + + const closed = withSchemas.flatMap((tool) => + closedObjects(tool.outputSchema, tool.name, 'outputSchema') + ); + expect(closed).toEqual([]); + }); +}); diff --git a/packages/mcp-server/src/__tests__/output-schemas-replay.test.ts b/packages/mcp-server/src/__tests__/output-schemas-replay.test.ts new file mode 100644 index 00000000..3d6bcda2 --- /dev/null +++ b/packages/mcp-server/src/__tests__/output-schemas-replay.test.ts @@ -0,0 +1,168 @@ +/** + * Realistic Form.io documents pass through the read and write tools intact. + * + * The client here lists the tools first, which is what makes the SDK client + * validate every structuredContent against the published output schema — the + * check a spec-compliant MCP client runs. The documents carry fields no schema + * enumerates and the `null`s Form.io stores for its Mixed fields, so a closed or + * over-narrow schema fails the call even though Form.io answered successfully. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { createTestClient, TEST_CWD, ToolRegister } 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), + }; +}); + +// The license gate is not under test; it passes the body through unchanged. +vi.mock('../revisions/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + gateRevisionsLicense: vi + .fn() + .mockImplementation(async ({ form }: { form: Record }) => ({ + licensed: true, + form, + })), +})); + +const { registerFormGetTool } = await import('../tools/form_get.js'); +const { registerFormRevisionGetTool } = await import('../tools/form_revision_get.js'); +const { registerFormUpdateTool } = await import('../tools/form_update.js'); +const { registerActionGetTool } = await import('../tools/action_get.js'); + +const FORM_ID = '67890abcdef012345678abcd'; +const ACTION_ID = 'abcdef012345678901234567'; + +// An Enterprise form as GET /form/{id} returns it: revision and PDF fields, a +// controller, e-signature settings, and the null Mixed fields a form saved +// without settings or properties carries. +const ENTERPRISE_FORM = { + _id: FORM_ID, + title: 'Intake', + name: 'intake', + path: 'intake', + type: 'form', + display: 'form', + tags: [], + components: [{ type: 'textfield', key: 'name', label: 'Name', input: true }], + access: [{ type: 'read_all', roles: ['5f0000000000000000000001'] }], + submissionAccess: [], + owner: null, + project: '5f0000000000000000000002', + machineName: 'intake', + created: '2026-01-01T00:00:00.000Z', + modified: '2026-01-02T00:00:00.000Z', + revisions: 'original', + submissionRevisions: '', + _vid: 4, + pdfComponents: [], + controller: '', + esign: {}, + settings: null, + properties: null, + fieldMatchAccess: { read: [], write: [] }, +}; + +const REVISION = { + ...ENTERPRISE_FORM, + _id: '5f0000000000000000000003', + _rid: FORM_ID, + _vid: 3, + _vnote: '@formio/mcp: add name field', + _vuser: 'admin@example.com', + revisionId: '5f0000000000000000000004', +}; + +const ACTION = { + _id: ACTION_ID, + name: 'save', + title: 'Save Submission', + form: FORM_ID, + handler: ['before'], + method: ['create', 'update'], + priority: 10, + condition: null, + settings: null, + machineName: 'intake:save', + deleted: null, +}; + +async function validatingClient(register: ToolRegister): Promise { + const { client } = await createTestClient(register); + // Listing caches each tool's output validator; every later call is checked. + await client.listTools(); + return client; +} + +describe('recorded Form.io documents through validating clients', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('form_get returns a form carrying unlisted fields and null Mixed fields', async () => { + mockFormioFetch.mockResolvedValue(ENTERPRISE_FORM); + const client = await validatingClient(registerFormGetTool); + + const result = await client.callTool({ + name: 'form_get', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID }, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual(ENTERPRISE_FORM); + }); + + it('form_revision_get returns a revision carrying its revision fields', async () => { + mockFormioFetch.mockResolvedValue(REVISION); + const client = await validatingClient(registerFormRevisionGetTool); + + const result = await client.callTool({ + name: 'form_revision_get', + arguments: { cwd: TEST_CWD, formIdOrPath: FORM_ID, version: '3' }, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual(REVISION); + }); + + it('form_update returns the saved form carrying unlisted fields and null Mixed fields', async () => { + mockFormioFetch.mockImplementation( + async (_path: string, _params: unknown, _cfg: unknown, options?: { method?: string }) => + options?.method === 'PUT' ? ENTERPRISE_FORM : { ...ENTERPRISE_FORM } + ); + const client = await validatingClient(registerFormUpdateTool); + + const result = await client.callTool({ + name: 'form_update', + arguments: { + cwd: TEST_CWD, + formId: FORM_ID, + form: { title: 'Intake', components: ENTERPRISE_FORM.components }, + note: 'retitle', + }, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual(ENTERPRISE_FORM); + }); + + it('action_get returns an action whose condition and settings are null', async () => { + mockFormioFetch.mockResolvedValue(ACTION); + const client = await validatingClient(registerActionGetTool); + + const result = await client.callTool({ + name: 'action_get', + arguments: { cwd: TEST_CWD, formId: FORM_ID, actionId: ACTION_ID }, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual(ACTION); + }); +}); diff --git a/packages/mcp-server/src/__tests__/package-exports.test.ts b/packages/mcp-server/src/__tests__/package-exports.test.ts new file mode 100644 index 00000000..1487ecb4 --- /dev/null +++ b/packages/mcp-server/src/__tests__/package-exports.test.ts @@ -0,0 +1,61 @@ +/** + * The package's interface is its `formio-mcp` binary. Nothing under `dist/` is a + * supported import, so the package publishes no library entry: `exports` exposes + * `package.json` alone, which closes every deep import. + */ + +import fs from 'fs'; +import { createRequire } from 'module'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { describe, expect, it } from 'vitest'; + +const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PACKAGE_JSON = path.join(PACKAGE_DIR, 'package.json'); + +interface PackageManifest { + name: string; + main?: string; + exports?: unknown; + bin?: Record; +} + +function manifest(): PackageManifest { + return JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8')) as PackageManifest; +} + +// Resolved as the package resolves its own name, which applies its `exports` map. +const selfRequire = createRequire(PACKAGE_JSON); + +function resolutionError(specifier: string): string | undefined { + try { + selfRequire.resolve(specifier); + return undefined; + } catch (error) { + return (error as { code?: string }).code; + } +} + +describe('@formio/mcp package entry points', () => { + it('exports package.json and nothing else', () => { + expect(manifest().exports).toEqual({ './package.json': './package.json' }); + }); + + it('declares no library entry', () => { + expect(manifest()).not.toHaveProperty('main'); + }); + + it('still declares the formio-mcp binary', () => { + expect(manifest().bin).toEqual({ 'formio-mcp': './dist/stdio.js' }); + }); + + it('resolves its package.json by name', () => { + expect(selfRequire.resolve(`${manifest().name}/package.json`)).toBe(PACKAGE_JSON); + }); + + it('refuses a deep import into dist', () => { + expect(resolutionError(`${manifest().name}/dist/server.js`)).toBe( + 'ERR_PACKAGE_PATH_NOT_EXPORTED' + ); + }); +}); diff --git a/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts b/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts index 980ff7cc..43dfde34 100644 --- a/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts +++ b/packages/mcp-server/src/__tests__/project-resolution-errors.test.ts @@ -24,12 +24,14 @@ const PROJECT_SCOPED_CALLS: Array<{ name: string; args: Record { name: 'form_get', args: { formIdOrPath: 'contact' } }, { name: 'form_create', args: { form: FORM } }, { name: 'form_update', args: { formId: OBJECT_ID, note: 'n', form: FORM } }, - { name: 'form_revisions_list', args: { formIdOrPath: 'contact' } }, + { name: 'form_publish', args: { formId: OBJECT_ID, note: 'n' } }, + { name: 'form_revert', args: { formId: OBJECT_ID, version: '1', note: 'n' } }, + { name: 'form_revision_list', args: { formIdOrPath: 'contact' } }, { name: 'form_revision_get', args: { formIdOrPath: 'contact', version: '1' } }, { name: 'role_list', args: {} }, - { name: 'role_create', args: { title: 'Manager' } }, + { name: 'role_create', args: { role: { title: 'Manager' } } }, { name: 'role_update', args: { roleId: OBJECT_ID, role: { title: 'Manager' } } }, - { name: 'action_types_list', args: { formId: OBJECT_ID } }, + { name: 'action_type_list', args: { formId: OBJECT_ID } }, { name: 'action_type_get', args: { formId: OBJECT_ID, actionName: 'save' } }, { name: 'action_create', args: { formId: OBJECT_ID, action: ACTION } }, { name: 'action_list', args: { formId: OBJECT_ID } }, @@ -79,10 +81,10 @@ describe('project-scoped tools with no resolvable project', () => { } ); - it('hello succeeds without any project configuration', async () => { + it('server_status succeeds without any project configuration', async () => { const client = await connectAllTools(); - const result = await client.callTool({ name: 'hello', arguments: {} }); + const result = await client.callTool({ name: 'server_status', arguments: {} }); expect(result.isError).toBeFalsy(); }); diff --git a/packages/mcp-server/src/__tests__/project-resolver.test.ts b/packages/mcp-server/src/__tests__/project-resolver.test.ts index 2013a0b6..3377d7ef 100644 --- a/packages/mcp-server/src/__tests__/project-resolver.test.ts +++ b/packages/mcp-server/src/__tests__/project-resolver.test.ts @@ -631,17 +631,14 @@ describe('cwdSchema', () => { expect(cwdSchema.safeParse('/workspace/pkg-a').success).toBe(true); }); - // The description is read on every tool call, more reliably than the server's - // instructions, so it has to carry the SAME precedence the resolver - // implements. It used to say a FORMIO_PROJECT_URL in the environment "takes - // precedence over every mapping" and made cwd unnecessary — the pre-reorder - // model, and now backwards: the environment is the weakest source, so omitting - // cwd cannot be made safe by setting a variable. - it('names every source of a project, narrowest scope first', () => { - expect(cwdSchema.description).toMatch(/project_set/); - expect(cwdSchema.description).toMatch(/FORMIO_PROJECT_URL/); - expect(cwdSchema.description).toContain('formio.json'); - expect(cwdSchema.description).toMatch(/weakest/i); + // The description is repeated on every tool, so it says only that cwd is optional + // and what an omitted one resolves to. When to pass one, and which RECORD wins for + // that directory — the committed file, the mapping, the environment — are the server + // instructions' to state, once; a shortened copy here is the one that drifts. + it('says cwd is optional and what an omitted cwd defaults to', () => { + expect(cwdSchema.description).toMatch(/optional/i); + expect(cwdSchema.description).toMatch(/defaults to the client's workspace root/); + expect(cwdSchema.description).not.toMatch(/on every call/i); }); it('does not claim the environment pins the project or replaces cwd', () => { diff --git a/packages/mcp-server/src/__tests__/project_set-error-codes.test.ts b/packages/mcp-server/src/__tests__/project_set-error-codes.test.ts new file mode 100644 index 00000000..921e9ed5 --- /dev/null +++ b/packages/mcp-server/src/__tests__/project_set-error-codes.test.ts @@ -0,0 +1,69 @@ +// project_set answers a refusal the way every other tool does: `[CODE] message` in +// the text, and the code under `_meta['io.form/error']`. A refusal of the caller's +// arguments is INVALID_ARGUMENT; a project map that cannot be read is +// CONFIG_UNREADABLE, so the caller does not rewrite a file that is merely unreadable. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { ERROR_META_KEY } from '../mcp-responses.js'; +import { registerProjectSetTool } from '../tools/project_set.js'; +import { connectTools } from './test-helpers.js'; + +interface ToolResult { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + _meta?: Record; +} + +async function call(args: Record): Promise { + const client = await connectTools((server) => + registerProjectSetTool(server, { cwd: () => '/w/server-cwd' }) + ); + return (await client.callTool({ name: 'project_set', arguments: args })) as ToolResult; +} + +function textOf(result: ToolResult): string { + return result.content.map((part) => part.text ?? '').join('\n'); +} + +describe('project_set refusals carry a code', () => { + it.each([ + ['a value that is not a URL', { projectUrl: 'not a url', cwd: '/w/a' }, 'must be a valid URL'], + ['neither URL', { cwd: '/w/b' }, 'Pass at least one of projectUrl or baseUrl'], + [ + 'a deployment for an unmapped directory', + { baseUrl: 'https://forms.mysite.com', cwd: '/w/c' }, + 'projectUrl is required for /w/c', + ], + // An empty string is a value, not an omission: treated as absent, it would leave + // the mapped value in place and report success for a write the caller did not ask for. + ['an empty projectUrl', { projectUrl: '', cwd: '/w/e' }, 'projectUrl is empty'], + [ + 'an empty baseUrl', + { projectUrl: 'https://next.form.io', baseUrl: '', cwd: '/w/f' }, + 'baseUrl is empty', + ], + ['a blank projectUrl', { projectUrl: ' ', cwd: '/w/g' }, 'projectUrl is empty'], + ])('refuses %s with INVALID_ARGUMENT', async (_label, args, expected) => { + const result = await call(args); + + expect(result.isError).toBe(true); + expect(textOf(result).startsWith('[INVALID_ARGUMENT] ')).toBe(true); + expect(textOf(result)).toContain(expected); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('INVALID_ARGUMENT'); + }); + + it('reports an unreadable project map as CONFIG_UNREADABLE', async () => { + const dir = path.join(os.homedir(), '.formio'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'projects.json'), '{ not json', 'utf8'); + + const result = await call({ projectUrl: 'https://next.form.io', cwd: '/w/d' }); + + expect(result.isError).toBe(true); + expect(textOf(result).startsWith('[CONFIG_UNREADABLE] ')).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('CONFIG_UNREADABLE'); + }); +}); diff --git a/packages/mcp-server/src/__tests__/revisions-flows.test.ts b/packages/mcp-server/src/__tests__/revisions-flows.test.ts new file mode 100644 index 00000000..4f48a9ab --- /dev/null +++ b/packages/mcp-server/src/__tests__/revisions-flows.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TEST_CONFIG } from './test-helpers.js'; +import { ToolError } from '../tool-errors.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const { saveDraft, publishDraft } = await import('../revisions/flows.js'); + +const FORM_ID = '67890abcdef012345678abcd'; + +describe('saveDraft', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + // An empty draft is never written by accident: a body carrying none of the + // fields a draft holds would stage nothing but the note. + it('refuses a body with none of the draft fields as INVALID_ARGUMENT, writing nothing', async () => { + const attempt = saveDraft({ + formId: FORM_ID, + form: { _id: FORM_ID, title: 'Renamed', path: 'renamed' }, + _vnote: 'n', + cfg: TEST_CONFIG, + }); + + await expect(attempt).rejects.toBeInstanceOf(ToolError); + await expect(attempt).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' }); + await expect(attempt).rejects.toThrow(/components/); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); +}); + +describe('publishDraft', () => { + beforeEach(() => { + mockFormioFetch.mockReset(); + }); + + it('refuses with NO_DRAFT when the draft route falls back to the live form', async () => { + mockFormioFetch.mockResolvedValue({ _id: FORM_ID, _vid: 2 }); + + await expect( + publishDraft({ formId: FORM_ID, _vnote: 'n', cfg: TEST_CONFIG }) + ).rejects.toMatchObject({ code: 'NO_DRAFT' }); + }); +}); diff --git a/packages/mcp-server/src/__tests__/revisions-history-acceptance.test.ts b/packages/mcp-server/src/__tests__/revisions-history-acceptance.test.ts new file mode 100644 index 00000000..0c7227f3 --- /dev/null +++ b/packages/mcp-server/src/__tests__/revisions-history-acceptance.test.ts @@ -0,0 +1,336 @@ +/** + * Saving a form without revision history is an argument the caller passes, never a + * prompt the server raises. The client here declares elicitation, so a server that + * still asked out of band would reach its handler — and every case fails if it does. + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { freshProjectUrl, stubRevisionsLicence, TEST_CONFIG, TEST_CWD } from './test-helpers.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; +import { writeProjectEntry } from '../project-map.js'; + +const mockFormioFetch = vi.fn(); +vi.mock('../formio-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formioFetch: (...args: unknown[]) => mockFormioFetch(...args), + }; +}); + +const mockOpenInBrowser = vi.fn(); +vi.mock('../browser-launch.js', async (importOriginal) => ({ + ...(await importOriginal()), + openInBrowser: (...args: unknown[]) => mockOpenInBrowser(...args), +})); + +const { registerFormCreateTool } = await import('../tools/form_create.js'); +const { registerFormUpdateTool } = await import('../tools/form_update.js'); + +const FORM_ID = '67890abcdef012345678abcd'; +const NEW_FORM = { title: 'Contact', name: 'contact', path: 'contact', components: [] }; + +interface ToolResult { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + _meta?: Record; +} + +type FetchOptions = { method?: string; body?: Record } | undefined; + +const elicitations: unknown[] = []; + +function writes() { + return mockFormioFetch.mock.calls.filter(([, , , options]) => (options as FetchOptions)?.method); +} + +function writtenBody(): Record | undefined { + const [, , , options] = writes()[0] ?? []; + return (options as FetchOptions)?.body; +} + +function codeOf(result: ToolResult) { + return result._meta?.[ERROR_META_KEY]?.code; +} + +function textOf(result: ToolResult) { + return result.content.map((part) => part.text ?? '').join('\n'); +} + +/** A client that supports elicitation, on a project whose deployment no test has probed. */ +async function connect(projectUrl: string = freshProjectUrl()) { + writeProjectEntry({ cwd: TEST_CWD, env: { FORMIO_PROJECT_URL: projectUrl } }); + const server = new McpServer({ name: 'test', version: '0.0.0' }); + registerFormCreateTool(server, TEST_CONFIG); + registerFormUpdateTool(server, TEST_CONFIG); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client( + { name: 'eliciting-client', version: '0.0.0' }, + { capabilities: { elicitation: {} } } + ); + client.setRequestHandler(ElicitRequestSchema, async (request) => { + elicitations.push(request); + return { action: 'decline' }; + }); + await client.connect(clientTransport); + const call = async (name: string, args: Record) => + (await client.callTool({ name, arguments: { cwd: TEST_CWD, ...args } })) as ToolResult; + return { call, projectUrl }; +} + +/** The stored form a standard update reads before it writes. */ +function storedForm(revisions: string) { + mockFormioFetch.mockImplementation((_path: string, _p: unknown, _c: unknown, o: FetchOptions) => + Promise.resolve(o?.method ? { _id: FORM_ID } : { _id: FORM_ID, name: 'contact', revisions }) + ); +} + +beforeEach(() => { + mockFormioFetch.mockReset(); + mockFormioFetch.mockResolvedValue({ _id: FORM_ID }); + mockOpenInBrowser.mockReset(); + elicitations.length = 0; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + expect(elicitations, 'the server sent an elicitation request').toEqual([]); + expect(mockOpenInBrowser, 'the server opened a local page').not.toHaveBeenCalled(); +}); + +describe('acceptNoHistory is an argument of both form writes', () => { + it.each(['form_create', 'form_update'])('%s declares acceptNoHistory', async (name) => { + stubRevisionsLicence(true); + writeProjectEntry({ cwd: TEST_CWD, env: { FORMIO_PROJECT_URL: freshProjectUrl() } }); + const server = new McpServer({ name: 'test', version: '0.0.0' }); + registerFormCreateTool(server, TEST_CONFIG); + registerFormUpdateTool(server, TEST_CONFIG); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: 'c', version: '0.0.0' }); + await client.connect(clientTransport); + + const { tools } = await client.listTools(); + const properties = (tools.find((tool) => tool.name === name)?.inputSchema.properties ?? + {}) as Record; + expect(properties.acceptNoHistory?.type).toBe('boolean'); + expect(properties.acceptNoHistory?.description).toBeTruthy(); + }); +}); + +describe('on a deployment without the revisions licence', () => { + beforeEach(() => { + stubRevisionsLicence(false); + }); + + it('form_create refuses with HISTORY_NOT_ACCEPTED and sends no POST', async () => { + const { call } = await connect(); + + const result = await call('form_create', { form: NEW_FORM }); + + expect(result.isError).toBe(true); + expect(codeOf(result)).toBe('HISTORY_NOT_ACCEPTED'); + expect(textOf(result)).toMatch(/^\[HISTORY_NOT_ACCEPTED\] /); + expect(textOf(result)).toMatch(/ask the user/i); + expect(textOf(result)).toContain('acceptNoHistory: true'); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + it('form_create with acceptNoHistory creates the form without a revisions setting', async () => { + const { call } = await connect(); + + const result = await call('form_create', { + form: { ...NEW_FORM, revisions: 'original' }, + acceptNoHistory: true, + }); + + expect(result.isError).toBeFalsy(); + expect(writes()).toHaveLength(1); + expect(writtenBody()).toEqual(NEW_FORM); + }); + + it('form_update refuses with HISTORY_NOT_ACCEPTED and sends no PUT', async () => { + const { call } = await connect(); + + const result = await call('form_update', { formId: FORM_ID, form: NEW_FORM, note: 'n' }); + + expect(result.isError).toBe(true); + expect(codeOf(result)).toBe('HISTORY_NOT_ACCEPTED'); + expect(writes()).toEqual([]); + }); + + it('form_update with acceptNoHistory saves without a revisions setting', async () => { + const { call } = await connect(); + + const result = await call('form_update', { + formId: FORM_ID, + form: { ...NEW_FORM, revisions: 'current' }, + note: 'n', + acceptNoHistory: true, + }); + + expect(result.isError).toBeFalsy(); + expect(writtenBody()).toEqual({ ...NEW_FORM, _vnote: '@formio/mcp: n' }); + }); +}); + +describe('a licensed form whose revisions are off', () => { + beforeEach(() => { + stubRevisionsLicence(true); + storedForm(''); + }); + + it('form_update refuses with HISTORY_NOT_ACCEPTED, naming both ways forward, and sends no PUT', async () => { + const { call } = await connect(); + + const result = await call('form_update', { formId: FORM_ID, form: NEW_FORM, note: 'n' }); + + expect(result.isError).toBe(true); + expect(codeOf(result)).toBe('HISTORY_NOT_ACCEPTED'); + expect(textOf(result)).toMatch(/ask the user/i); + expect(textOf(result)).toContain('acceptNoHistory: true'); + expect(textOf(result)).toContain('"original"'); + expect(textOf(result)).toContain('"current"'); + expect(writes()).toEqual([]); + }); + + it('a retry with form.revisions "original" enables revisions and saves', async () => { + const { call } = await connect(); + + const result = await call('form_update', { + formId: FORM_ID, + form: { ...NEW_FORM, revisions: 'original' }, + note: 'n', + }); + + expect(result.isError).toBeFalsy(); + expect(writtenBody()).toEqual({ ...NEW_FORM, revisions: 'original', _vnote: '@formio/mcp: n' }); + }); + + it('a retry with acceptNoHistory saves without enabling revisions', async () => { + const { call } = await connect(); + + const result = await call('form_update', { + formId: FORM_ID, + form: NEW_FORM, + note: 'n', + acceptNoHistory: true, + }); + + expect(result.isError).toBeFalsy(); + expect(writtenBody()).toEqual({ ...NEW_FORM, _vnote: '@formio/mcp: n' }); + }); + + // Every call stands alone: an acceptance is not remembered for the next save. + it('asks again on the next save after one was accepted', async () => { + const { call } = await connect(); + + await call('form_update', { + formId: FORM_ID, + form: NEW_FORM, + note: 'n', + acceptNoHistory: true, + }); + const second = await call('form_update', { formId: FORM_ID, form: NEW_FORM, note: 'n' }); + + expect(codeOf(second)).toBe('HISTORY_NOT_ACCEPTED'); + expect(writes()).toHaveLength(1); + }); +}); + +// Setting `revisions: ""` is the agent turning history off, so on a licensed +// deployment it needs the same acceptance as any other save without history — +// whatever the stored form says. +describe('explicitly disabling history on a licensed deployment', () => { + beforeEach(() => { + stubRevisionsLicence(true); + }); + + const disabled = { ...NEW_FORM, revisions: '' }; + const cases = [ + { + label: 'form_create with revisions ""', + stored: undefined, + call: 'form_create', + args: { form: disabled }, + expected: disabled, + }, + { + label: 'form_update with revisions "" on a form whose revisions are on', + stored: 'original', + call: 'form_update', + args: { formId: FORM_ID, form: disabled, note: 'n' }, + expected: { ...disabled, _vnote: '@formio/mcp: n' }, + }, + { + label: 'form_update with revisions "" on a form whose revisions are off', + stored: '', + call: 'form_update', + args: { formId: FORM_ID, form: disabled, note: 'n' }, + expected: { ...disabled, _vnote: '@formio/mcp: n' }, + }, + ]; + + it.each(cases)('$label refuses with HISTORY_NOT_ACCEPTED and writes nothing', async (c) => { + if (c.stored !== undefined) storedForm(c.stored); + const { call } = await connect(); + + const result = await call(c.call, c.args); + + expect(result.isError).toBe(true); + expect(codeOf(result)).toBe('HISTORY_NOT_ACCEPTED'); + expect(textOf(result)).toMatch(/ask the user/i); + expect(textOf(result)).toContain('acceptNoHistory: true'); + expect(textOf(result)).toContain('"original"'); + expect(textOf(result)).toContain('"current"'); + expect(writes()).toEqual([]); + }); + + it.each(cases)('$label is saved as given with acceptNoHistory', async (c) => { + if (c.stored !== undefined) storedForm(c.stored); + const { call } = await connect(); + + const result = await call(c.call, { ...c.args, acceptNoHistory: true }); + + expect(result.isError).toBeFalsy(); + expect(writes()).toHaveLength(1); + expect(writtenBody()).toEqual(c.expected); + }); +}); + +describe('the retired licence-consent file', () => { + const consentFile = () => path.join(os.homedir(), '.formio', 'revisions-license-consent.json'); + + beforeEach(() => { + stubRevisionsLicence(false); + }); + + it('is not read: an approval recorded in it does not stand in for acceptNoHistory', async () => { + const projectUrl = freshProjectUrl(); + const deployment = projectUrl.replace(/\/[^/]+$/, ''); + const { call } = await connect(projectUrl); + const recorded = JSON.stringify({ [deployment]: true }); + fs.writeFileSync(consentFile(), recorded); + + const result = await call('form_create', { form: NEW_FORM }); + + expect(codeOf(result)).toBe('HISTORY_NOT_ACCEPTED'); + expect(fs.readFileSync(consentFile(), 'utf-8')).toBe(recorded); + }); + + it('is not written when a save without history is accepted', async () => { + const { call } = await connect(); + + await call('form_create', { form: NEW_FORM, acceptNoHistory: true }); + + expect(fs.existsSync(consentFile())).toBe(false); + }); +}); diff --git a/packages/mcp-server/src/__tests__/revisions-license-base-url.test.ts b/packages/mcp-server/src/__tests__/revisions-license-base-url.test.ts index ddb66d3d..82d27435 100644 --- a/packages/mcp-server/src/__tests__/revisions-license-base-url.test.ts +++ b/packages/mcp-server/src/__tests__/revisions-license-base-url.test.ts @@ -1,16 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { describe, it, expect } from 'vitest'; import { ResolvedFormioConfig } from '../config.js'; - -vi.mock('../revisions/browser-prompts.js', () => ({ - requestRevisionsLicenseConsent: vi.fn(), -})); - -import { gateRevisionsLicense } from '../revisions/license.js'; -import { requestRevisionsLicenseConsent } from '../revisions/browser-prompts.js'; - -const mockConsent = vi.mocked(requestRevisionsLicenseConsent); -const server = {} as McpServer; +import { gateRevisionsLicense, requireRevisionsLicense } from '../revisions/license.js'; // The Security Module flag lives on the deployment, fetched anonymously from // ${baseUrl}/config.js. With no base URL there is nothing to ask — and the two @@ -59,15 +49,7 @@ describe('the revisions license gate with an unresolved base URL', () => { ], ])('names the write that reaches the record — %s', async (_label, config, expected) => { await expect( - gateRevisionsLicense( - server, - { ...config, apiKey: 'secret-key' }, - { - actionLabel: 'publish', - requiresRevisions: true, - form: { title: 'Contact' }, - } - ) + requireRevisionsLicense({ ...config, apiKey: 'secret-key' }, 'publish') ).rejects.toThrow(expected); }); @@ -75,8 +57,7 @@ describe('the revisions license gate with an unresolved base URL', () => { // that was wrong. it('does not name the mapping-only command for a committed project', async () => { await expect( - gateRevisionsLicense( - server, + requireRevisionsLicense( { projectUrl: 'https://myproject.mysite.com', cwd: '/w/app', @@ -84,28 +65,24 @@ describe('the revisions license gate with an unresolved base URL', () => { committedFilePath: '/w/app/formio.json', apiKey: 'secret-key', }, - { actionLabel: 'publish', requiresRevisions: true, form: { title: 'Contact' } } + 'publish' ) ).rejects.toThrow(/^(?!.*set --base-url )/s); }); it('demands the base URL when revisions are explicitly required', async () => { - await expect( - gateRevisionsLicense(server, unresolved, { - actionLabel: 'publish', - requiresRevisions: true, - form: { title: 'Contact' }, - }) - ).rejects.toThrow(/Base URL/i); + const attempt = requireRevisionsLicense(unresolved, 'publish'); + await expect(attempt).rejects.toThrow(/Base URL/i); + await expect(attempt).rejects.toMatchObject({ code: 'BASE_URL_UNRESOLVED' }); }); // Stripping here would discard the user's setting on the strength of a probe // that never ran. it('demands the base URL rather than stripping a revisions setting', async () => { await expect( - gateRevisionsLicense(server, unresolved, { + gateRevisionsLicense({ + cfg: unresolved, actionLabel: 'create', - requiresRevisions: false, form: { title: 'Contact', revisions: 'current' }, }) ).rejects.toThrow(/Base URL/i); @@ -117,9 +94,9 @@ describe('the revisions license gate with an unresolved base URL', () => { it('passes a form with no revisions setting through untouched', async () => { const form = { title: 'Contact', components: [] }; - const result = await gateRevisionsLicense(server, unresolved, { + const result = await gateRevisionsLicense({ + cfg: unresolved, actionLabel: 'create', - requiresRevisions: false, form, }); @@ -134,11 +111,15 @@ describe('the revisions license gate with an unresolved base URL', () => { // with an underivable deployment ever reads. const rejectionMessage = async (form: Record, requiresRevisions: boolean) => { try { - await gateRevisionsLicense(server, unresolved, { - actionLabel: 'create', - requiresRevisions, - form, - }); + if (requiresRevisions) { + await requireRevisionsLicense(unresolved, 'create'); + } else { + await gateRevisionsLicense({ + cfg: unresolved, + actionLabel: 'create', + form, + }); + } } catch (error) { return error instanceof Error ? error.message : String(error); } @@ -166,14 +147,4 @@ describe('the revisions license gate with an unresolved base URL', () => { expect(message).toMatch(/--base-url|baseUrl/); expect(message).toMatch(/do not ask for the Project URL again/i); }); - - it('does not claim the deployment is unlicensed by prompting for consent', async () => { - await gateRevisionsLicense(server, unresolved, { - actionLabel: 'create', - requiresRevisions: false, - form: { title: 'Contact' }, - }); - - expect(mockConsent).not.toHaveBeenCalled(); - }); }); diff --git a/packages/mcp-server/src/__tests__/revisions.test.ts b/packages/mcp-server/src/__tests__/revisions.test.ts index e468363f..1b9b8705 100644 --- a/packages/mcp-server/src/__tests__/revisions.test.ts +++ b/packages/mcp-server/src/__tests__/revisions.test.ts @@ -1,9 +1,5 @@ -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(); @@ -15,31 +11,11 @@ vi.mock('../formio-client.js', async (importOriginal) => { }; }); -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 } = +const { checkRevisionsLicensed, gateRevisionsLicense, requireRevisionsLicense } = await import('../revisions/license.js'); -const { gateRevisionsTracking } = await import('../revisions/tracking.js'); +const { gateFormHistory } = await import('../revisions/history.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`, @@ -55,14 +31,16 @@ const stubLicensed = (licensed: boolean) => }) ); -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'); +// The shape undici gives a request that never got a response. +const networkFailure = () => + new TypeError('fetch failed', { + cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:443'), { + code: 'ECONNREFUSED', + }), + }); +const FORM_ID = '67890abcdef012345678abcd'; beforeEach(() => { vi.resetAllMocks(); @@ -70,22 +48,57 @@ beforeEach(() => { }); describe('checkRevisionsLicensed', () => { - it('returns true when /config.js contains sac = true', async () => { + it('is licensed when /config.js contains sac = true', async () => { stubLicensed(true); - expect(await checkRevisionsLicensed(cfgFor(uniqueBaseUrl()))).toBe(true); + expect(await checkRevisionsLicensed(cfgFor(uniqueBaseUrl()))).toEqual({ state: 'licensed' }); }); - it('returns false when /config.js reports sac = false', async () => { + it('is unlicensed when /config.js reports sac = false', async () => { stubLicensed(false); - expect(await checkRevisionsLicensed(cfgFor(uniqueBaseUrl()))).toBe(false); + expect(await checkRevisionsLicensed(cfgFor(uniqueBaseUrl()))).toEqual({ + state: 'unlicensed', + }); }); - 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('is unlicensed when /config.js answers without the flag', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve('var x = 1;') }) + ); + expect(await checkRevisionsLicensed(cfgFor(uniqueBaseUrl()))).toEqual({ + state: 'unlicensed', + }); }); - it('caches per baseUrl — second call does not refetch', async () => { + it('is unknown, with NETWORK_ERROR, when the probe gets no response', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(networkFailure())); + const baseUrl = uniqueBaseUrl(); + + const licence = await checkRevisionsLicensed(cfgFor(baseUrl)); + + expect(licence).toMatchObject({ state: 'unknown', failure: { code: 'NETWORK_ERROR' } }); + }); + + it('is unknown, with UPSTREAM_ERROR, when the probe is answered with an error status', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 502 })); + + const licence = await checkRevisionsLicensed(cfgFor(uniqueBaseUrl())); + + expect(licence).toMatchObject({ state: 'unknown', failure: { code: 'UPSTREAM_ERROR' } }); + }); + + it('bounds the probe with a timeout', async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue({ ok: true, text: () => Promise.resolve('sac = true') }); + vi.stubGlobal('fetch', fetchSpy); + + await checkRevisionsLicensed(cfgFor(uniqueBaseUrl())); + + expect((fetchSpy.mock.calls[0][1] as RequestInit).signal).toBeInstanceOf(AbortSignal); + }); + + it('caches a definite answer per baseUrl — second call does not refetch', async () => { const baseUrl = uniqueBaseUrl(); const fetchSpy = vi .fn() @@ -95,245 +108,239 @@ describe('checkRevisionsLicensed', () => { await checkRevisionsLicensed(cfgFor(baseUrl)); expect(fetchSpy).toHaveBeenCalledTimes(1); }); -}); -describe('confirmProceedWithoutRevisions', () => { - it('throws USER CANCELLED when user cancels via elicitation', async () => { + // A failed probe is not an answer, so it is not kept for long: long enough that a + // run of writes against an unreachable deployment does not re-probe on every one, + // short enough that a deployment which was only briefly unreachable is asked again. + it('keeps an unknown answer for 60 seconds, then probes again', async () => { const baseUrl = uniqueBaseUrl(); - stubLicensed(false); - const server = makeServer({ elicitation: true, elicit: elicitCancel() }); + const fetchSpy = vi + .fn() + .mockRejectedValueOnce(networkFailure()) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValue({ ok: true, text: () => Promise.resolve('sac = true') }); + vi.stubGlobal('fetch', fetchSpy); + const clock = { ms: 1_000_000 }; + const now = () => clock.ms; + const check = () => checkRevisionsLicensed(cfgFor(baseUrl), { now }); - await expect( - confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form') - ).rejects.toThrow(/USER CANCELLED/); - }); + expect(await check()).toMatchObject({ state: 'unknown', failure: { code: 'NETWORK_ERROR' } }); + clock.ms += 59_999; + expect(await check()).toMatchObject({ state: 'unknown', failure: { code: 'NETWORK_ERROR' } }); + expect(fetchSpy).toHaveBeenCalledTimes(1); - 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 }); + clock.ms += 1; + expect(await check()).toMatchObject({ state: 'unknown', failure: { code: 'UPSTREAM_ERROR' } }); + expect(fetchSpy).toHaveBeenCalledTimes(2); - await confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form'); + clock.ms += 60_000; + expect(await check()).toEqual({ state: 'licensed' }); + expect(fetchSpy).toHaveBeenCalledTimes(3); - expect(mockRequestLicenseConsent).toHaveBeenCalledWith(baseUrl, 'create this form'); - const data = JSON.parse(await fs.readFile(CONSENT_FILE, 'utf-8')); - expect(data[baseUrl]).toBe(true); + // A definite answer does not expire. + clock.ms += 10 * 60_000; + expect(await check()).toEqual({ state: 'licensed' }); + expect(fetchSpy).toHaveBeenCalledTimes(3); }); +}); - it('throws USER CANCELLED when browser prompt cancels', async () => { - const baseUrl = uniqueBaseUrl(); +describe('requireRevisionsLicense', () => { + it('refuses with LICENSE_REQUIRED on an unlicensed deployment', async () => { stubLicensed(false); - mockRequestLicenseConsent.mockResolvedValue('cancel'); - const server = makeServer({ elicitation: false }); + + const attempt = requireRevisionsLicense(cfgFor(uniqueBaseUrl()), 'save a draft of this form'); + await expect(attempt).rejects.toMatchObject({ code: 'LICENSE_REQUIRED' }); + await expect(attempt).rejects.toThrow(/Security Module/); + }); + + it('passes on a licensed deployment', async () => { + stubLicensed(true); await expect( - confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form') - ).rejects.toThrow(/USER CANCELLED/); + requireRevisionsLicense(cfgFor(uniqueBaseUrl()), 'publish this form') + ).resolves.toBeUndefined(); }); - it('persists positive consent to file and skips re-prompt for same baseUrl', async () => { + // Unknown is not unlicensed: LICENSE_REQUIRED would tell the user to stop using + // drafts on a deployment that may well support them. + it('refuses with NETWORK_ERROR naming the probe URL when the probe gets no response', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(networkFailure())); const baseUrl = uniqueBaseUrl(); - stubLicensed(false); - const elicit = elicitAccept('continue'); - const server = makeServer({ elicitation: true, elicit }); - await confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form'); + const attempt = requireRevisionsLicense(cfgFor(baseUrl), 'publish this form'); + + await expect(attempt).rejects.toMatchObject({ code: 'NETWORK_ERROR' }); + await expect(attempt).rejects.toThrow(/could not be determined/); + await expect(attempt).rejects.toThrow(`${baseUrl}/config.js`); + await expect(attempt).rejects.toThrow(/ECONNREFUSED/); + }); + + it('refuses with UPSTREAM_ERROR naming the probe URL when the probe is answered non-2xx', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })); + const baseUrl = uniqueBaseUrl(); - 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); + const attempt = requireRevisionsLicense(cfgFor(baseUrl), 'revert this form'); - // second call → no re-prompt - await confirmProceedWithoutRevisions(server, cfgFor(baseUrl), 'create this form'); - expect(elicit).toHaveBeenCalledTimes(1); + await expect(attempt).rejects.toMatchObject({ code: 'UPSTREAM_ERROR' }); + await expect(attempt).rejects.toThrow(/could not be determined/); + await expect(attempt).rejects.toThrow(`${baseUrl}/config.js`); + await expect(attempt).rejects.toThrow(/500/); }); }); describe('gateRevisionsLicense', () => { - it('throws when requiresRevisions and unlicensed', async () => { + it('refuses with HISTORY_NOT_ACCEPTED when unlicensed and the caller has not accepted', 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/); + const attempt = gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', + form: { components: [] }, + }); + await expect(attempt).rejects.toMatchObject({ code: 'HISTORY_NOT_ACCEPTED' }); + await expect(attempt).rejects.toThrow(/acceptNoHistory: true/); }); - it('strips revisions when requiresRevisions is false and unlicensed', async () => { + it('strips revisions when unlicensed and the caller accepted saving without history', async () => { stubLicensed(false); - const server = makeServer({ elicitation: true, elicit: elicitAccept('continue') }); - const result = await gateRevisionsLicense(server, cfgFor(uniqueBaseUrl()), { + const result = await gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), actionLabel: 'update this form', - requiresRevisions: false, form: { revisions: 'original', components: [] }, + acceptNoHistory: true, }); expect(result.licensed).toBe(false); expect(result.form).toEqual({ components: [] }); }); - it('passes through unchanged when licensed', async () => { + it('refuses with HISTORY_NOT_ACCEPTED when licensed and the body sets revisions to ""', 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()), { + const attempt = gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), actionLabel: 'update this form', - requiresRevisions: false, - form, + form: { revisions: '', components: [] }, }); - expect(result.licensed).toBe(true); - expect(result.form).toBe(form); + await expect(attempt).rejects.toMatchObject({ code: 'HISTORY_NOT_ACCEPTED' }); }); -}); -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('passes a body setting revisions to "" through when licensed and accepted', async () => { + stubLicensed(true); - 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, + const form = { revisions: '', components: [] }; + const result = await gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', + form, + acceptNoHistory: true, }); - expect(elicit).not.toHaveBeenCalled(); - expect(out).toEqual({ components: [] }); + expect(result).toEqual({ licensed: true, form }); }); - 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 }); + // An unknown licence never strips `revisions` and never refuses on the licence's + // account: either would act on a probe that produced no answer. + it.each([ + ['no response', () => vi.fn().mockRejectedValue(networkFailure())], + ['an error status', () => vi.fn().mockResolvedValue({ ok: false, status: 503 })], + ])('leaves the body as-is when the probe got %s', async (_label, probe) => { + vi.stubGlobal('fetch', probe()); + + const withRevisions = { revisions: 'original', components: [] }; + const kept = await gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', + form: withRevisions, + }); + expect(kept.form).toBe(withRevisions); + expect(kept.licensed).toBe(false); + expect(kept.licenceUnknown).toBe(true); - const out = await gateRevisionsTracking(server, { - formId, - form: { components: [] }, - licensed: true, - cfg, + const withoutRevisions = { components: [] }; + const passed = await gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', + form: withoutRevisions, }); - expect(elicit).toHaveBeenCalledTimes(1); - expect(out.revisions).toBe('original'); + expect(passed.form).toBe(withoutRevisions); }); - 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 }); + // The body turning history off is the caller's own decision, and it needs the same + // acceptance whatever the probe says. + it('still refuses a body that turns history off when the licence is unknown', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(networkFailure())); - const out = await gateRevisionsTracking(server, { - formId, + const attempt = gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', form: { revisions: '', components: [] }, - licensed: true, - cfg, }); - expect(elicit).toHaveBeenCalledTimes(1); - expect(out.revisions).toBe('current'); + await expect(attempt).rejects.toMatchObject({ code: 'HISTORY_NOT_ACCEPTED' }); + await expect(attempt).rejects.toThrow(/sets revisions to ""/); }); - 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 }); + it('passes through unchanged when licensed', async () => { + stubLicensed(true); - 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, + const form = { revisions: 'original' as const, components: [] }; + const result = await gateRevisionsLicense({ + cfg: cfgFor(uniqueBaseUrl()), + actionLabel: 'update this form', + form, }); - expect(elicit).not.toHaveBeenCalled(); - expect(out2).toEqual({ components: [{ type: 'textfield', key: 'x' }] }); + expect(result.licensed).toBe(true); + expect(result.form).toBe(form); }); +}); - 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/); - }); +describe('gateFormHistory', () => { + const cfg = cfgFor('https://tracking.local'); - 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 }); + it.each(['original', 'current'] as const)( + 'does not read the stored form when the caller enables revisions: "%s"', + async (mode) => { + await gateFormHistory({ cfg, formId: FORM_ID, form: { revisions: mode, components: [] } }); + expect(mockFormioFetch).not.toHaveBeenCalled(); + } + ); - const out = await gateRevisionsTracking(server, { - formId, - form: { components: [] }, - licensed: true, + it('does not read the stored form when the caller accepted saving without history', async () => { + await gateFormHistory({ cfg, + formId: FORM_ID, + form: { components: [] }, + acceptNoHistory: true, }); - - expect(mockRequestRevisionsConsent).toHaveBeenCalledWith('demo', formId); - expect(out.revisions).toBe('original'); + expect(mockFormioFetch).not.toHaveBeenCalled(); }); - it('does not prompt when stored.revisions is truthy', async () => { - const formId = uniqueFormId(); + it('passes when the stored form has revisions on', async () => { 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: [] }); + await gateFormHistory({ cfg, formId: FORM_ID, form: { components: [] } }); + // Only the two fields the gate reads: the whole definition is not needed. + expect(mockFormioFetch).toHaveBeenCalledWith( + `form/${FORM_ID}`, + { select: 'revisions,name' }, + cfg + ); }); + + // Echoing the stored "" back is not a decision about history. + it.each([{}, { revisions: '' }])( + 'refuses with HISTORY_NOT_ACCEPTED when the stored form has revisions off (body %j)', + async (extra) => { + mockFormioFetch.mockResolvedValue({ name: 'demo', revisions: '' }); + + const attempt = gateFormHistory({ + cfg, + formId: FORM_ID, + form: { components: [], ...extra }, + }); + await expect(attempt).rejects.toMatchObject({ code: 'HISTORY_NOT_ACCEPTED' }); + await expect(attempt).rejects.toThrow(/"demo"/); + } + ); }); describe('helpers', () => { diff --git a/packages/mcp-server/src/__tests__/role_create.test.ts b/packages/mcp-server/src/__tests__/role_create.test.ts index 6eea7be1..0a67bfaa 100644 --- a/packages/mcp-server/src/__tests__/role_create.test.ts +++ b/packages/mcp-server/src/__tests__/role_create.test.ts @@ -13,11 +13,46 @@ describe('role_create tool', () => { mockFormioFetch.mockReset(); }); - it('is listed in available tools with title, description, default, and admin parameters', async () => { + it('takes the working directory and a role object whose title is required', async () => { mockFormioFetch.mockResolvedValue({}); const { client } = await createTestClient(registerRoleCreateTool); const { tools } = await client.listTools(); - expect(tools.map((t) => t.name)).toContain('role_create'); + const tool = tools.find((t) => t.name === 'role_create'); + expect(tool).toBeDefined(); + expect(Object.keys(tool!.inputSchema.properties ?? {}).sort()).toEqual(['cwd', 'role']); + expect(tool!.inputSchema.required).toContain('role'); + const role = (tool!.inputSchema.properties as Record>).role; + expect(Object.keys(role.properties as Record).sort()).toEqual([ + 'admin', + 'default', + 'description', + 'title', + ]); + expect(role.required).toEqual(['title']); + }); + + it('refuses flat role fields without a request', async () => { + const { client } = await createTestClient(registerRoleCreateTool); + + const result = await client.callTool({ + name: 'role_create', + arguments: { cwd: TEST_CWD, title: 'Employee' }, + }); + + expect(result.isError).toBe(true); + expect(mockFormioFetch).not.toHaveBeenCalled(); + }); + + it('refuses a role without a title without a request', async () => { + const { client } = await createTestClient(registerRoleCreateTool); + + const result = await client.callTool({ + name: 'role_create', + arguments: { cwd: TEST_CWD, role: { description: 'No title' } }, + }); + + expect(result.isError).toBe(true); + expect(mockFormioFetch).not.toHaveBeenCalled(); }); it('sends POST /role with title only and returns created role', async () => { @@ -32,7 +67,7 @@ describe('role_create tool', () => { const result = await client.callTool({ name: 'role_create', - arguments: { cwd: TEST_CWD, title: 'Employee' }, + arguments: { cwd: TEST_CWD, role: { title: 'Employee' } }, }); expect(mockFormioFetch).toHaveBeenCalledWith('role', {}, TEST_CONFIG, { @@ -52,7 +87,7 @@ describe('role_create tool', () => { default: false, admin: false, }; - await client.callTool({ name: 'role_create', arguments: { cwd: TEST_CWD, ...args } }); + await client.callTool({ name: 'role_create', arguments: { cwd: TEST_CWD, role: args } }); expect(mockFormioFetch).toHaveBeenCalledWith('role', {}, TEST_CONFIG, { method: 'POST', @@ -66,7 +101,7 @@ describe('role_create tool', () => { const result = await client.callTool({ name: 'role_create', - arguments: { cwd: TEST_CWD, title: 'Test' }, + arguments: { cwd: TEST_CWD, role: { title: 'Test' } }, }); expect(result.isError).toBe(true); diff --git a/packages/mcp-server/src/__tests__/role_list.test.ts b/packages/mcp-server/src/__tests__/role_list.test.ts index 3d87d059..4329dbed 100644 --- a/packages/mcp-server/src/__tests__/role_list.test.ts +++ b/packages/mcp-server/src/__tests__/role_list.test.ts @@ -8,48 +8,65 @@ vi.mock('../formio-client.js', () => ({ const { registerRoleListTool } = await import('../tools/role_list.js'); +function roles(count: number) { + return Array.from({ length: count }, (_, index) => ({ _id: `${index}`, title: `Role ${index}` })); +} + describe('role_list tool', () => { beforeEach(() => { mockFormioFetch.mockReset(); + mockFormioFetch.mockResolvedValue({ data: [], total: 0 }); }); - it('is listed in available tools with optional select parameter', async () => { - mockFormioFetch.mockResolvedValue([]); + it('is listed in available tools', async () => { const { client } = await createTestClient(registerRoleListTool); const { tools } = await client.listTools(); expect(tools.map((t) => t.name)).toContain('role_list'); }); - it('calls GET /role with no query params by default and returns JSON array', async () => { - const roles = [ - { _id: '69d65f4e040fa2cea2572254', title: 'Administrator' }, - { _id: '69d65f4e040fa2cea2572255', title: 'Authenticated' }, - ]; - mockFormioFetch.mockResolvedValue(roles); + // Form.io's own default page is 10, so a project with 12 roles used to come back + // truncated with nothing saying so. + it('sends limit 100 and skip 0 by default and returns every role with the total', async () => { + const page = roles(12); + mockFormioFetch.mockResolvedValue({ data: page, total: 12 }); const { client } = await createTestClient(registerRoleListTool); const result = await client.callTool({ name: 'role_list', arguments: { cwd: TEST_CWD } }); - expect(mockFormioFetch).toHaveBeenCalledWith('role', { select: undefined }, TEST_CONFIG); - expect(result.structuredContent).toEqual({ roles, count: 2 }); - expect(result.content).toEqual([ - { type: 'text', text: JSON.stringify({ roles, count: 2 }, null, 2) }, - ]); + expect(mockFormioFetch).toHaveBeenCalledWith('role', { limit: '100', skip: '0' }, TEST_CONFIG, { + withMeta: true, + }); + expect(result.structuredContent).toEqual({ roles: page, total: 12, hasMore: false }); }); - it('forwards custom select as query parameter', async () => { - mockFormioFetch.mockResolvedValue([]); + it('forwards limit, skip, sort and select', async () => { const { client } = await createTestClient(registerRoleListTool); - await client.callTool({ name: 'role_list', arguments: { cwd: TEST_CWD, select: '_id,title' } }); + await client.callTool({ + name: 'role_list', + arguments: { cwd: TEST_CWD, limit: 5, skip: 5, sort: 'title', select: '_id,title' }, + }); expect(mockFormioFetch).toHaveBeenCalledWith( 'role', - expect.objectContaining({ select: '_id,title' }), - TEST_CONFIG + { limit: '5', skip: '5', sort: 'title', select: '_id,title' }, + TEST_CONFIG, + { withMeta: true } ); }); + it('reports hasMore when Form.io holds more roles than the page', async () => { + mockFormioFetch.mockResolvedValue({ data: roles(5), total: 12 }); + const { client } = await createTestClient(registerRoleListTool); + + const result = await client.callTool({ + name: 'role_list', + arguments: { cwd: TEST_CWD, limit: 5 }, + }); + + expect(result.structuredContent).toMatchObject({ total: 12, hasMore: true }); + }); + it('returns isError true on API error', async () => { mockFormioFetch.mockRejectedValue(new Error('Form.io API error: 401 Unauthorized')); const { client } = await createTestClient(registerRoleListTool); diff --git a/packages/mcp-server/src/__tests__/role_update.test.ts b/packages/mcp-server/src/__tests__/role_update.test.ts index 97983877..8ffd8a57 100644 --- a/packages/mcp-server/src/__tests__/role_update.test.ts +++ b/packages/mcp-server/src/__tests__/role_update.test.ts @@ -17,13 +17,13 @@ describe('role_update tool', () => { mockFormioFetch.mockReset(); }); - it('is listed in available tools with full-replacement guidance', async () => { + it('is listed in available tools with update guidance', async () => { mockFormioFetch.mockResolvedValue({}); const { client } = await createTestClient(registerRoleUpdateTool); const { tools } = await client.listTools(); const tool = tools.find((t) => t.name === 'role_update'); expect(tool).toBeDefined(); - expect(tool!.description).toContain('full replacement'); + expect(tool!.description).toContain('left out keep'); }); it('returns isError for invalid roleId format', async () => { @@ -40,6 +40,13 @@ describe('role_update tool', () => { }); expect(result.isError).toBe(true); + // Refused in the handler, so the refusal carries its code: a schema failure is + // reported by the SDK as bare text. + const text = (result.content as Array<{ text: string }>)[0].text; + expect(text.startsWith('[INVALID_ARGUMENT] ')).toBe(true); + expect(text).toContain('roleId'); + expect(text).toContain('role_list'); + expect(mockFormioFetch).not.toHaveBeenCalled(); }); it('sends PUT /role/:roleId with role body and returns updated role', async () => { diff --git a/packages/mcp-server/src/__tests__/runnable-remedies.test.ts b/packages/mcp-server/src/__tests__/runnable-remedies.test.ts index ca8f51e5..92f57a56 100644 --- a/packages/mcp-server/src/__tests__/runnable-remedies.test.ts +++ b/packages/mcp-server/src/__tests__/runnable-remedies.test.ts @@ -1,18 +1,13 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { PROJECT_CLI } from '../cli-launch.js'; import { runProjectCommand } from '../cli/project-command.js'; import { requireBaseUrl, resolveProject } from '../project-resolver.js'; import { writeProjectEntry } from '../project-map.js'; -vi.mock('../revisions/browser-prompts.js', () => ({ - requestRevisionsLicenseConsent: vi.fn(), -})); - -import { gateRevisionsLicense } from '../revisions/license.js'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { requireRevisionsLicense } from '../revisions/license.js'; // The rule every message on this surface obeys, shared by every describe below // because it binds them all, whichever record the message is about. @@ -97,11 +92,7 @@ describe('the commands these messages print', () => { const message = await (async () => { try { - await gateRevisionsLicense({} as McpServer, config, { - actionLabel: 'publish', - requiresRevisions: true, - form: { title: 'Contact' }, - }); + await requireRevisionsLicense(config, 'publish'); } catch (error) { return error instanceof Error ? error.message : String(error); } diff --git a/packages/mcp-server/src/__tests__/server-empty-env.test.ts b/packages/mcp-server/src/__tests__/server-empty-env.test.ts index 12258363..5344f25b 100644 --- a/packages/mcp-server/src/__tests__/server-empty-env.test.ts +++ b/packages/mcp-server/src/__tests__/server-empty-env.test.ts @@ -48,17 +48,19 @@ describe('createServer with an empty environment', () => { expect(names).toEqual( expect.arrayContaining([ - 'hello', + 'server_status', 'form_create', 'form_get', 'form_list', 'form_update', - 'form_revisions_list', + 'form_publish', + 'form_revert', + 'form_revision_list', 'form_revision_get', 'role_create', 'role_list', 'role_update', - 'action_types_list', + 'action_type_list', 'action_type_get', 'action_create', 'action_list', diff --git a/packages/mcp-server/src/__tests__/server_status.test.ts b/packages/mcp-server/src/__tests__/server_status.test.ts new file mode 100644 index 00000000..7a6183ef --- /dev/null +++ b/packages/mcp-server/src/__tests__/server_status.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { writeProjectEntry } from '../project-map.js'; +import { SERVER_VERSION } from '../server.js'; +import { registerServerStatusTool } from '../tools/server_status.js'; +import { connectTools } from './test-helpers.js'; + +// The first call to make when other tools fail: it needs no project and no +// credentials, so it answers on a bare install, and what it reports about the +// caller's directory is the same answer project_get gives. + +interface StatusPayload { + name: string; + version: string; + status: string; + cwd: string; + projectUrl?: string; + baseUrl?: string; + projectUrlSource?: string; + baseUrlSource?: string; +} + +function payload(result: unknown): StatusPayload { + const { structuredContent } = (result ?? {}) as { structuredContent?: StatusPayload }; + if (!structuredContent) { + throw new Error('server_status returned no structuredContent'); + } + return structuredContent; +} + +describe('server_status tool', () => { + const cwd = '/workspace/status-app'; + const mockFetch = vi.fn(); + + beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('is listed in available tools', async () => { + const client = await connectTools((server) => registerServerStatusTool(server, {})); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain('server_status'); + }); + + it('reports the version and not-configured when nothing configures a project', async () => { + const client = await connectTools((server) => registerServerStatusTool(server, {})); + + const result = await client.callTool({ name: 'server_status', arguments: { cwd } }); + + expect(result.isError).toBeFalsy(); + const status = payload(result); + expect(status.name).toBe('formio-mcp'); + expect(status.version).toBe(SERVER_VERSION); + expect(status.status).toBe('not-configured'); + expect(status.cwd).toBe(cwd); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('reports the resolved project, its deployment and their sources for a mapped directory', async () => { + writeProjectEntry({ cwd, env: { FORMIO_PROJECT_URL: 'https://examples.form.io' } }); + const client = await connectTools((server) => registerServerStatusTool(server, {})); + + const result = await client.callTool({ name: 'server_status', arguments: { cwd } }); + + expect(result.isError).toBeFalsy(); + const status = payload(result); + expect(status.version).toBe(SERVER_VERSION); + expect(status.status).toBe('ok'); + expect(status.projectUrl).toBe('https://examples.form.io'); + expect(status.baseUrl).toBe('https://api.form.io'); + expect(status.projectUrlSource).toBe('mapping'); + expect(status.baseUrlSource).toBe('derived'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('names the server version in its text', async () => { + const client = await connectTools((server) => registerServerStatusTool(server, {})); + + const result = await client.callTool({ name: 'server_status', arguments: { cwd } }); + + const text = (result.content as { text: string }[]).map((part) => part.text).join('\n'); + expect(text).toContain(`formio-mcp ${SERVER_VERSION}`); + }); + + it('answers for the server process directory when no cwd is given', async () => { + const client = await connectTools((server) => + registerServerStatusTool(server, {}, { cwd: () => cwd }) + ); + + const result = await client.callTool({ name: 'server_status', arguments: {} }); + + expect(result.isError).toBeFalsy(); + expect(payload(result).cwd).toBe(cwd); + }); +}); diff --git a/packages/mcp-server/src/__tests__/setup.ts b/packages/mcp-server/src/__tests__/setup.ts index e131519d..43f13d01 100644 --- a/packages/mcp-server/src/__tests__/setup.ts +++ b/packages/mcp-server/src/__tests__/setup.ts @@ -20,6 +20,9 @@ process.env.USERPROFILE = tmpHome; // where it happens to be exported. A case that wants one sets it itself. delete process.env.FORMIO_PROJECT_URL; delete process.env.FORMIO_BASE_URL; +// The same for the directory a client launch names: under Claude Code it is set for +// every spawned process, and a tool given no cwd would resolve against it. +delete process.env.CLAUDE_PROJECT_DIR; beforeEach(() => { fs.rmSync(path.join(tmpHome, '.formio'), { recursive: true, force: true }); diff --git a/packages/mcp-server/src/__tests__/standalone-guidance.test.ts b/packages/mcp-server/src/__tests__/standalone-guidance.test.ts index 8493defb..44815bce 100644 --- a/packages/mcp-server/src/__tests__/standalone-guidance.test.ts +++ b/packages/mcp-server/src/__tests__/standalone-guidance.test.ts @@ -123,7 +123,9 @@ describe('guidance for a stand-alone server', () => { const { tools } = await client.listTools(); const projectSet = tools.find((tool) => tool.name === 'project_set'); - expect(projectSet?.description).toMatch(/login/i); + // The rule, not the reason: why the Base URL matters (it builds the portal-login + // URL) is the README's, and the description carries only what a caller acts on. + expect(projectSet?.description).toMatch(/Base URL is derived/); expect(projectSet?.description).toContain('https://api.form.io'); await client.close(); }); diff --git a/packages/mcp-server/src/__tests__/test-helpers.ts b/packages/mcp-server/src/__tests__/test-helpers.ts index 962b1b82..fa4cee37 100644 --- a/packages/mcp-server/src/__tests__/test-helpers.ts +++ b/packages/mcp-server/src/__tests__/test-helpers.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; @@ -91,3 +92,30 @@ export async function createTestClient( return { client, server, cwd }; } + +let licenceProbeDeployments = 0; + +/** + * A project URL on a deployment no earlier test has probed. The revisions licence + * is cached per deployment for the life of the module, so a test that needs a + * particular answer uses a deployment the cache has not seen. + */ +export function freshProjectUrl(): string { + licenceProbeDeployments += 1; + return `https://licence-${licenceProbeDeployments}.invalid/sub/example`; +} + +/** + * Answers the anonymous `{baseUrl}/config.js` probe with the given Security Module + * flag. Only the licence probe reaches the global fetch in tests that mock + * `formioFetch`. + */ +export function stubRevisionsLicence(licensed: boolean) { + const probe = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => `var sac = ${licensed};`, + })); + vi.stubGlobal('fetch', probe); + return probe; +} diff --git a/packages/mcp-server/src/__tests__/token-validation.test.ts b/packages/mcp-server/src/__tests__/token-validation.test.ts index 35379d68..f44dd737 100644 --- a/packages/mcp-server/src/__tests__/token-validation.test.ts +++ b/packages/mcp-server/src/__tests__/token-validation.test.ts @@ -83,4 +83,23 @@ describe('validateToken', () => { expect((mockFetch.mock.calls.at(-1)?.[1] as RequestInit).redirect).toBe('manual'); expect(result).toBe(false); }); + + // A refused connection is the same failure every other request reports: a + // NETWORK_ERROR naming the cause and the URL, not a bare "fetch failed". + it('reports a refused connection as NETWORK_ERROR naming the cause and URL', async () => { + const cause = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:443'), { + code: 'ECONNREFUSED', + }); + mockFetch.mockRejectedValue(new TypeError('fetch failed', { cause })); + + const attempt = validateToken({ + baseUrl: 'https://formio.invalid/sub', + projectUrl: 'https://formio.invalid/sub/example', + jwt: 'valid-token', + }); + + await expect(attempt).rejects.toMatchObject({ code: 'NETWORK_ERROR' }); + await expect(attempt).rejects.toThrow(/ECONNREFUSED/); + await expect(attempt).rejects.toThrow('https://formio.invalid/sub/current'); + }); }); diff --git a/packages/mcp-server/src/__tests__/tool-description-budget.test.ts b/packages/mcp-server/src/__tests__/tool-description-budget.test.ts new file mode 100644 index 00000000..e42ebae8 --- /dev/null +++ b/packages/mcp-server/src/__tests__/tool-description-budget.test.ts @@ -0,0 +1,281 @@ +/** + * The tool list is read into an agent's context on every connect, so its size is a + * cost every session pays. The budget is measured on the serialized `tools/list` + * result, the bytes a client actually receives. + * + * What repeats is what costs most: the `cwd` description appears on every + * project-scoped tool, so it says only that it is optional and what it defaults to, + * and leaves when to pass it, and the rest of the rules, to the server instructions, + * which every client receives once. + * + * A description is kept where a caller acts on it. On an output schema that is the + * fields a caller branches on; every other field is typed and left undescribed. On an + * input schema it is every argument this server defines; the fields nested inside a + * form, action or role body are Form.io's own, documented by the formio-schema and + * formio-actions skills, so they carry none — except where this server gives one a + * meaning of its own (`form.revisions`, which `acceptNoHistory` depends on). + * + * A Form.io document's output schema is open, so a field it does not declare still + * passes through. It declares only the fields a caller acts on — the ones the skills + * and the tools read — rather than every field Form.io stores. + */ + +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { describe, expect, it } from 'vitest'; +import { connectTools, TEST_CONFIG } from './test-helpers.js'; +import { registerAllTools } from '../tools/index.js'; +import { SERVER_INSTRUCTIONS } from '../server.js'; +import { + actionDocument, + actionTypeDocument, + actionTypeInfoDocument, + formDocument, + revisionSummaryDocument, + roleDocument, + templateDocument, +} from '../output-schemas.js'; + +const TOOL_LIST_BUDGET = 42_000; +const CWD_DESCRIPTION_BUDGET = 100; + +// Output fields a caller branches on, by path from the result's root (`[]` steps into +// an array's items). Every other output field carries no description. +const DESCRIBED_OUTPUT_FIELDS = ['ok', 'status', 'total', 'hasMore', 'remedy', 'remedy.supply']; + +// The fields each Form.io document's output schema declares: the ones a caller acts on. +// `access` and `submissionAccess` stay on a form because the auth skills read them from +// form_get before editing them. +const DECLARED_DOCUMENT_FIELDS = [ + { + name: 'form', + schema: formDocument, + fields: [ + '_id', + 'title', + 'name', + 'path', + 'type', + 'display', + 'components', + 'revisions', + 'access', + 'submissionAccess', + ], + }, + { + name: 'action', + schema: actionDocument, + fields: [ + '_id', + 'name', + 'title', + 'form', + 'handler', + 'method', + 'settings', + 'condition', + 'priority', + ], + }, + { name: 'role', schema: roleDocument, fields: ['_id', 'title', 'admin', 'default'] }, + { + name: 'revision summary', + schema: revisionSummaryDocument, + fields: ['_id', '_vid', '_vnote', '_vuser', 'created'], + }, + { name: 'action type', schema: actionTypeDocument, fields: ['name', 'title'] }, + { + name: 'action type info', + schema: actionTypeInfoDocument, + fields: ['name', 'title', 'settingsForm'], + }, + { + name: 'template', + schema: templateDocument, + fields: ['roles', 'resources', 'forms', 'actions'], + }, +]; + +const ANNOTATION_TITLE_WORDS = 4; + +// The arguments this server defines, as opposed to Form.io's own body fields: each +// keeps its description wherever it appears. +const SERVER_ARGUMENTS = [ + 'cwd', + 'note', + 'draft', + 'acceptNoHistory', + 'formId', + 'formIdOrPath', + 'version', + 'limit', + 'skip', + 'sort', + 'select', + 'tags', + 'projectUrl', + 'baseUrl', +]; + +// Body fields, by tool and path, that this server gives a meaning of its own. +const DESCRIBED_BODY_FIELDS: Record = { + form_create: ['form.revisions'], + form_update: ['form.revisions'], +}; + +async function listTools(): Promise { + const client = await connectTools((server) => registerAllTools(server, TEST_CONFIG)); + const { tools } = await client.listTools(); + return tools; +} + +interface SchemaNode { + description?: string; + properties?: Record; + items?: SchemaNode | SchemaNode[]; + additionalProperties?: SchemaNode | boolean; + anyOf?: SchemaNode[]; + oneOf?: SchemaNode[]; + allOf?: SchemaNode[]; +} + +// Every path under `node` that carries a description, `node` itself included. +function describedPaths(node: SchemaNode | undefined, at: string): string[] { + if (!node || typeof node !== 'object') return []; + const join = (step: string) => (at === '' ? step : `${at}.${step}`); + const items = node.items === undefined ? [] : [node.items].flat(); + const additional = + typeof node.additionalProperties === 'object' ? [node.additionalProperties] : []; + return [ + ...(node.description !== undefined && at !== '' ? [at] : []), + ...Object.entries(node.properties ?? {}).flatMap(([name, child]) => + describedPaths(child, join(name)) + ), + ...items.flatMap((child) => describedPaths(child, `${at}[]`)), + ...additional.flatMap((child) => describedPaths(child, join('{}'))), + ...[...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? [])].flatMap((child) => + describedPaths(child, at) + ), + ]; +} + +function cwdDescriptions(tools: Tool[]): Array<{ tool: string; description: string }> { + return tools.flatMap((tool) => { + const cwd = (tool.inputSchema.properties ?? {}).cwd as { description?: string } | undefined; + return cwd ? [{ tool: tool.name, description: cwd.description ?? '' }] : []; + }); +} + +describe('tool description budget', () => { + it(`serializes tools/list in at most ${TOOL_LIST_BUDGET} characters`, async () => { + const size = JSON.stringify(await listTools()).length; + expect(size).toBeLessThanOrEqual(TOOL_LIST_BUDGET); + }); + + it(`keeps every cwd description within ${CWD_DESCRIPTION_BUDGET} characters`, async () => { + const tools = await listTools(); + const descriptions = cwdDescriptions(tools); + expect(descriptions.length).toBeGreaterThan(0); + const over = descriptions.filter( + ({ description }) => description.length > CWD_DESCRIPTION_BUDGET + ); + expect(over.map(({ tool, description }) => `${tool}: ${description.length}`)).toEqual([]); + }); + + it('says that cwd is optional and defaults to the client workspace root', async () => { + const missing = cwdDescriptions(await listTools()).filter( + ({ description }) => + !/optional/i.test(description) || + !/defaults to the client's workspace root/i.test(description) + ); + expect(missing.map(({ tool }) => tool)).toEqual([]); + }); + + it('describes only the output fields a caller branches on', async () => { + const extra = (await listTools()).flatMap((tool) => + describedPaths(tool.outputSchema as SchemaNode | undefined, '') + .filter((field) => !DESCRIBED_OUTPUT_FIELDS.includes(field)) + .map((field) => `${tool.name}: ${field}`) + ); + expect(extra).toEqual([]); + }); + + it('tells a project_set caller what a false `ok` means', async () => { + const tool = (await listTools()).find(({ name }) => name === 'project_set'); + const ok = (tool?.outputSchema?.properties ?? {}).ok as SchemaNode | undefined; + expect(ok?.description).toMatch(/WAS written/); + expect(ok?.description).toMatch(/message/); + expect(ok?.description).toMatch(/do not retry/i); + expect((ok?.description ?? '').length).toBeLessThanOrEqual(130); + }); + + it.each(DECLARED_DOCUMENT_FIELDS)( + 'declares only the $name fields a caller acts on, none of them required', + ({ schema, fields }) => { + expect(Object.keys(schema.shape).sort()).toEqual([...fields].sort()); + const json = schema.toJSONSchema() as { required?: string[] }; + expect(json.required ?? []).toEqual([]); + } + ); + + it(`keeps every annotation title within ${ANNOTATION_TITLE_WORDS} words`, async () => { + const long = (await listTools()) + .map(({ name, annotations }) => ({ name, title: annotations?.title ?? '' })) + .filter(({ title }) => title.split(/\s+/).length > ANNOTATION_TITLE_WORDS); + expect(long.map(({ name, title }) => `${name}: ${title}`)).toEqual([]); + }); + + it('keeps the description on every argument this server defines', async () => { + const tools = await listTools(); + const missing = tools.flatMap((tool) => { + const properties = (tool.inputSchema.properties ?? {}) as Record; + return SERVER_ARGUMENTS.filter( + (name) => name in properties && !properties[name].description + ).map((name) => `${tool.name}: ${name}`); + }); + const unseen = SERVER_ARGUMENTS.filter( + (name) => !tools.some((tool) => name in (tool.inputSchema.properties ?? {})) + ); + expect(missing).toEqual([]); + expect(unseen).toEqual([]); + }); + + it('leaves Form.io body fields to the skills, except those the server defines', async () => { + const tools = await listTools(); + const described = tools.flatMap((tool) => + Object.entries((tool.inputSchema.properties ?? {}) as Record).flatMap( + ([name, argument]) => + describedPaths({ ...argument, description: undefined }, name).map((field) => ({ + tool: tool.name, + field, + })) + ) + ); + const extra = described.filter( + ({ tool, field }) => !(DESCRIBED_BODY_FIELDS[tool] ?? []).includes(field) + ); + const missing = Object.entries(DESCRIBED_BODY_FIELDS).flatMap(([tool, fields]) => + fields + .filter((field) => !described.some((entry) => entry.tool === tool && entry.field === field)) + .map((field) => `${tool}: ${field}`) + ); + expect(extra.map(({ tool, field }) => `${tool}: ${field}`)).toEqual([]); + expect(missing).toEqual([]); + }); + + // The short description points here, so the full rule has to be here: the order a + // directory is chosen in, and when an agent passes cwd itself. + it('leaves the full cwd and resolution rules in the server instructions', () => { + expect(SERVER_INSTRUCTIONS).toMatch( + /cwd argument[\s\S]*?client's workspace roots[\s\S]*?CLAUDE_PROJECT_DIR[\s\S]*?server's own working directory/ + ); + expect(SERVER_INSTRUCTIONS).toMatch(/several roots[^.]*INVALID_ARGUMENT/); + expect(SERVER_INSTRUCTIONS).toMatch( + /cwdSource is "server" or "claude-project-dir"[^.]*pass cwd/ + ); + expect(SERVER_INSTRUCTIONS).not.toMatch(/on every project-scoped call/); + expect(SERVER_INSTRUCTIONS).toMatch( + /narrowest-scope-first: a committed formio\.json[\s\S]*?then the per-directory mapping project_set writes, then FORMIO_PROJECT_URL[\s\S]*?weakest/ + ); + }); +}); diff --git a/packages/mcp-server/src/__tests__/tool-error-contract.test.ts b/packages/mcp-server/src/__tests__/tool-error-contract.test.ts new file mode 100644 index 00000000..56da80af --- /dev/null +++ b/packages/mcp-server/src/__tests__/tool-error-contract.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { connectTools, createTestClient, TEST_CWD, TEST_PROJECT_URL } from './test-helpers.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; + +vi.mock('../ensure-auth.js', () => ({ + ensureAuthenticated: vi.fn(), + resetAuthState: vi.fn(), + invalidateJwtCache: vi.fn(), +})); + +// The license gate is a pass-through here: these cases are about how a failed +// request is reported, not about revisions. +vi.mock('../revisions/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + gateRevisionsLicense: vi + .fn() + .mockImplementation(async ({ form }: { form: Record }) => ({ + licensed: true, + form, + })), +})); + +const { registerFormCreateTool } = await import('../tools/form_create.js'); +const { registerRoleListTool } = await import('../tools/role_list.js'); + +const FORM = { title: 'Contact', name: 'contact', path: 'contact', components: [] }; + +interface ToolResult { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + structuredContent?: unknown; + _meta?: Record; +} + +function errorOf(result: ToolResult) { + return result._meta?.[ERROR_META_KEY]; +} + +function textOf(result: ToolResult): string { + return result.content.map((part) => part.text ?? '').join('\n'); +} + +describe('tool errors carry a structured code', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reports a Form.io 400 on form_create as VALIDATION_FAILED with the body', async () => { + const body = JSON.stringify({ + status: 400, + message: 'form validation failed: path: The Path must be unique per Project.', + errors: { + path: { + path: 'path', + name: 'ValidatorError', + message: 'The Path must be unique per Project.', + }, + }, + }); + mockFetch.mockResolvedValue(new Response(body, { status: 400 })); + const { client } = await createTestClient(registerFormCreateTool); + + const result = (await client.callTool({ + name: 'form_create', + arguments: { cwd: TEST_CWD, form: FORM }, + })) as ToolResult; + + expect(result.isError).toBe(true); + expect(errorOf(result)).toEqual({ code: 'VALIDATION_FAILED', status: 400, body }); + expect(result.structuredContent).toBeUndefined(); + expect(textOf(result).startsWith('[VALIDATION_FAILED] ')).toBe(true); + expect(textOf(result)).toContain('The Path must be unique per Project.'); + }); + + it('reports a refused connection as NETWORK_ERROR naming the cause and URL', async () => { + const cause = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:443'), { + code: 'ECONNREFUSED', + }); + mockFetch.mockRejectedValue(new TypeError('fetch failed', { cause })); + const { client } = await createTestClient(registerFormCreateTool); + + const result = (await client.callTool({ + name: 'form_create', + arguments: { cwd: TEST_CWD, form: FORM }, + })) as ToolResult; + + expect(result.isError).toBe(true); + expect(errorOf(result)).toEqual({ code: 'NETWORK_ERROR' }); + expect(textOf(result)).toContain('ECONNREFUSED'); + expect(textOf(result)).toContain(`${TEST_PROJECT_URL}/form`); + }); + + it('reports an unconfigured directory as NOT_CONFIGURED with the existing remedy', async () => { + const client = await connectTools((server) => registerFormCreateTool(server, {})); + + const result = (await client.callTool({ + name: 'form_create', + arguments: { cwd: '/workspace/no-project-here', form: FORM }, + })) as ToolResult; + + expect(result.isError).toBe(true); + expect(errorOf(result)).toEqual({ code: 'NOT_CONFIGURED' }); + expect(textOf(result)).toContain('No Form.io project is configured'); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe('an error result passes through a client that validates output', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // listTools first: the SDK client caches each tool's output validator from the + // listing and validates structuredContent against it on every later call. + it.each([ + ['form_create', registerFormCreateTool, { form: FORM }], + ['role_list', registerRoleListTool, {}], + ] as const)('%s', async (name, register, args) => { + mockFetch.mockResolvedValue(new Response('Not Found', { status: 404 })); + const { client } = await createTestClient(register); + const { tools } = await client.listTools(); + expect(tools.find((tool) => tool.name === name)?.outputSchema).toBeDefined(); + + const result = (await client.callTool({ + name, + arguments: { cwd: TEST_CWD, ...args }, + })) as ToolResult; + + expect(result.isError).toBe(true); + expect(errorOf(result)?.code).toBe('NOT_FOUND'); + expect(textOf(result).startsWith('[NOT_FOUND] ')).toBe(true); + }); +}); diff --git a/packages/mcp-server/src/__tests__/tool-path-arguments.test.ts b/packages/mcp-server/src/__tests__/tool-path-arguments.test.ts index 062e41de..5ec9f89d 100644 --- a/packages/mcp-server/src/__tests__/tool-path-arguments.test.ts +++ b/packages/mcp-server/src/__tests__/tool-path-arguments.test.ts @@ -24,22 +24,18 @@ const PATH_ARGUMENTS = ['formIdOrPath', 'formId', 'actionId', 'actionName', 'ver // Valid values for every required argument, so a refusal is about the argument under test alone. const VALID_ARGUMENTS: Record> = { form_get: { formIdOrPath: 'user/login' }, - form_revisions_list: { formIdOrPath: 'user/login' }, + form_revision_list: { formIdOrPath: 'user/login' }, form_revision_get: { formIdOrPath: 'user/login', version: '3' }, - form_update: { - formId: FORM_ID, - form: { components: [] }, - note: 'Reverted to version 3', - revert: true, - version: '3', - }, + form_update: { formId: FORM_ID, form: { components: [] }, note: 'n' }, + form_publish: { formId: FORM_ID, note: 'n' }, + form_revert: { formId: FORM_ID, version: '3', note: 'Reverted to version 3' }, role_update: { roleId: FORM_ID, role: { title: 'Admin' } }, action_list: { formId: FORM_ID }, action_create: { formId: FORM_ID, action: ACTION }, action_get: { formId: FORM_ID, actionId: ACTION_ID }, action_update: { formId: FORM_ID, actionId: ACTION_ID, action: ACTION }, action_delete: { formId: FORM_ID, actionId: ACTION_ID }, - action_types_list: { formId: FORM_ID }, + action_type_list: { formId: FORM_ID }, action_type_get: { formId: FORM_ID, actionName: 'email' }, }; diff --git a/packages/mcp-server/src/__tests__/update-semantics.test.ts b/packages/mcp-server/src/__tests__/update-semantics.test.ts new file mode 100644 index 00000000..01565c2d --- /dev/null +++ b/packages/mcp-server/src/__tests__/update-semantics.test.ts @@ -0,0 +1,22 @@ +// Form.io's PUT (resourcejs) loads the stored document and sets the body onto it, so +// a top-level field the body leaves out keeps its stored value, and an array or +// object the body carries is stored as given rather than merged. The update tools +// describe that, not a full replacement that would drop omitted fields. + +import { describe, expect, it } from 'vitest'; +import { registerAllTools } from '../tools/index.js'; +import { connectTools, TEST_CONFIG } from './test-helpers.js'; + +describe('update tools describe what Form.io keeps', () => { + it.each(['form_update', 'role_update', 'action_update'])( + '%s says omitted fields keep their stored value', + async (name) => { + const client = await connectTools((server) => registerAllTools(server, TEST_CONFIG)); + const { tools } = await client.listTools(); + const description = tools.find((tool) => tool.name === name)?.description ?? ''; + + expect(description).not.toMatch(/full replacement|^Replace/i); + expect(description).toMatch(/left out keep/); + } + ); +}); diff --git a/packages/mcp-server/src/__tests__/workspace-directory.test.ts b/packages/mcp-server/src/__tests__/workspace-directory.test.ts new file mode 100644 index 00000000..37c93475 --- /dev/null +++ b/packages/mcp-server/src/__tests__/workspace-directory.test.ts @@ -0,0 +1,662 @@ +// The directory a call resolves against when the caller passes no `cwd`. +// +// A client spawns the server wherever it likes — a desktop app often in `/` — so the +// server's own working directory is the last resort, not the default. The order is: +// the `cwd` argument, the client's workspace roots (read with `roots/list`, and only +// from a client that declared `roots`), CLAUDE_PROJECT_DIR, then the process's own +// directory. Driven through a real SDK client over the in-memory transport, so the +// capability negotiation and the `roots/list` round trip are the SDK's own. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { pathToFileURL } from 'url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { FormioConfig } from '../config.js'; +import { ERROR_META_KEY } from '../mcp-responses.js'; +import { readProjectEntry, writeProjectEntry } from '../project-map.js'; +import { registerAllTools } from '../tools/index.js'; +import { ROOTS_TIMEOUT_MS, workspaceDirectory } from '../workspace-directory.js'; + +interface ToolResult { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + structuredContent?: Record; + _meta?: Record; +} + +const SERVER_CWD = '/workspace/server-spawn-directory'; + +interface ConnectOptions { + /** Roots the client reports; omitted, the client declares no `roots` capability. */ + roots?: () => string[]; + /** Replaces the client's `roots/list` handler. */ + listRoots?: () => Promise<{ roots: Array<{ uri: string }> }>; + rootsTimeoutMs?: number; + /** The launch environment's configuration; none by default. */ + config?: FormioConfig; +} + +interface Connected { + client: Client; + /** Every request method the server sent to the client. */ + sentToClient: string[]; + /** How many times the client answered `roots/list`. */ + rootsAnswered: () => number; +} + +async function connect(options: ConnectOptions = {}): Promise { + const server = new McpServer({ name: 'test', version: '0.0.0' }); + if (options.rootsTimeoutMs !== undefined) { + workspaceDirectory(server, { rootsTimeoutMs: options.rootsTimeoutMs }); + } + registerAllTools(server, options.config ?? {}, { cwd: () => SERVER_CWD }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const sentToClient: string[] = []; + const send = serverTransport.send.bind(serverTransport); + serverTransport.send = async (message, sendOptions) => { + if ('method' in message && 'id' in message) { + sentToClient.push(message.method); + } + return send(message, sendOptions); + }; + await server.connect(serverTransport); + + const declaresRoots = options.roots !== undefined || options.listRoots !== undefined; + const client = new Client( + { name: 'test-client', version: '0.0.0' }, + declaresRoots ? { capabilities: { roots: { listChanged: true } } } : {} + ); + let answered = 0; + if (declaresRoots) { + const roots = options.roots ?? (() => []); + client.setRequestHandler(ListRootsRequestSchema, async () => { + answered += 1; + return options.listRoots + ? options.listRoots() + : { roots: roots().map((dir) => ({ uri: pathToFileURL(dir).href })) }; + }); + } + await client.connect(clientTransport); + return { client, sentToClient, rootsAnswered: () => answered }; +} + +async function call( + client: Client, + name: string, + args: Record = {} +): Promise { + return (await client.callTool({ name, arguments: args })) as unknown as ToolResult; +} + +function text(result: ToolResult): string { + return result.content.map((item) => item.text ?? '').join('\n'); +} + +function report(result: ToolResult): Record { + expect(result.isError, text(result)).toBeFalsy(); + return result.structuredContent ?? {}; +} + +const tempDirs: string[] = []; + +/** A directory of its own; with a project, it carries a committed formio.json naming it. */ +function workspace(name: string, projectUrl?: string): string { + // realpath: on macOS the temp directory is a symlink, and a file URL round-trips the + // resolved path. + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `formio-roots-${name}-`))); + tempDirs.push(dir); + // Bounds the committed-file walk at this directory. + fs.mkdirSync(path.join(dir, '.git')); + if (projectUrl) { + fs.writeFileSync(path.join(dir, 'formio.json'), JSON.stringify({ projectUrl })); + } + return dir; +} + +beforeEach(() => { + vi.unstubAllEnvs(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + tempDirs.splice(0).forEach((dir) => fs.rmSync(dir, { recursive: true, force: true })); +}); + +describe('the directory a call resolves against', () => { + it('waits at most two seconds for roots/list by default', () => { + expect(ROOTS_TIMEOUT_MS).toBe(2_000); + }); + + it('uses the cwd argument over the client root', async () => { + const app = workspace('app', 'https://app.form.io'); + const other = workspace('other', 'https://other.form.io'); + const { client } = await connect({ roots: () => [app] }); + + const result = report(await call(client, 'project_get', { cwd: other })); + + expect(result.cwd).toBe(other); + expect(result.cwdSource).toBe('argument'); + expect(result.projectUrl).toBe('https://other.form.io'); + }); + + it("uses the client's single root as is", async () => { + const app = workspace('app'); + const { client } = await connect({ roots: () => [app] }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(app); + expect(result.cwdSource).toBe('client-root'); + }); + + it('uses the one root of several that has a committed formio.json', async () => { + const app = workspace('app', 'https://app.form.io'); + const lib = workspace('lib'); + const { client } = await connect({ roots: () => [lib, app] }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(app); + expect(result.cwdSource).toBe('client-root'); + expect(result.projectUrl).toBe('https://app.form.io'); + }); + + it('uses the one root of several that has a directory mapping', async () => { + const app = workspace('app'); + const lib = workspace('lib'); + writeProjectEntry({ cwd: app, env: { FORMIO_PROJECT_URL: 'https://mapped.form.io' } }); + const { client } = await connect({ roots: () => [app, lib] }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(app); + expect(result.projectUrl).toBe('https://mapped.form.io'); + }); + + it.each([ + ['different projects', ['https://one.form.io', 'https://two.form.io']], + // Two records are two records, even naming one URL: each carries its own deployment. + ['the same project URL from two files', ['https://same.form.io', 'https://same.form.io']], + ])('refuses with INVALID_ARGUMENT when two roots resolve %s', async (_case, urls) => { + const one = workspace('one', urls[0]); + const two = workspace('two', urls[1]); + const { client } = await connect({ roots: () => [one, two] }); + + const result = await call(client, 'project_get'); + + expect(result.isError).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('INVALID_ARGUMENT'); + expect(text(result)).toMatch(/^\[INVALID_ARGUMENT\]/); + expect(text(result)).toContain(one); + expect(text(result)).toContain(two); + expect(text(result)).toMatch(/pass cwd/i); + }); + + it('refuses a committed record in one root and a mapping in another', async () => { + const one = workspace('one', 'https://one.form.io'); + const two = workspace('two'); + writeProjectEntry({ cwd: two, env: { FORMIO_PROJECT_URL: 'https://two.form.io' } }); + const { client } = await connect({ roots: () => [one, two] }); + + const result = await call(client, 'project_get'); + + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('INVALID_ARGUMENT'); + }); + + // Two roots inside one repository walk up to the same formio.json: one project. + it('uses the first of several roots that all resolve the same record', async () => { + const repo = workspace('repo', 'https://monorepo.form.io'); + const a = path.join(repo, 'packages', 'a'); + const b = path.join(repo, 'packages', 'b'); + fs.mkdirSync(a, { recursive: true }); + fs.mkdirSync(b, { recursive: true }); + const lib = workspace('lib'); + const { client } = await connect({ roots: () => [lib, b, a] }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(b); + expect(result.cwdSource).toBe('client-root'); + expect(result.projectUrl).toBe('https://monorepo.form.io'); + }); + + // An environment-only launch resolves the same project for every directory, so + // roots that name no record of their own decide nothing and must not refuse. + it('falls through when none of several roots resolves a project record', async () => { + const one = workspace('one'); + const two = workspace('two'); + const { client } = await connect({ + roots: () => [one, two], + config: { projectUrl: 'https://environment.form.io' }, + }); + + const result = report(await call(client, 'project_get')); + + expect(result.status).toBe('ok'); + expect(result.cwdSource).toBe('server'); + expect(result.cwd).toBe(SERVER_CWD); + expect(result.projectUrl).toBe('https://environment.form.io'); + }); + + it('falls through to CLAUDE_PROJECT_DIR when none of several roots has a record', async () => { + const one = workspace('one'); + const two = workspace('two'); + const app = workspace('app'); + vi.stubEnv('CLAUDE_PROJECT_DIR', app); + const { client } = await connect({ roots: () => [one, two] }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(app); + expect(result.cwdSource).toBe('claude-project-dir'); + }); + + // A broken record is still the record the user wrote for that root: choosing the + // other root silently would target a project nobody picked for this directory. + it('counts an unreadable formio.json as a record', async () => { + const broken = workspace('broken'); + fs.writeFileSync(path.join(broken, 'formio.json'), '{ not json'); + const other = workspace('other', 'https://other.form.io'); + const { client } = await connect({ roots: () => [broken, other] }); + + const result = await call(client, 'project_get'); + + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('INVALID_ARGUMENT'); + }); + + it('chooses the root whose formio.json is unreadable and surfaces its error', async () => { + const broken = workspace('broken'); + fs.writeFileSync(path.join(broken, 'formio.json'), '{ not json'); + const plain = workspace('plain'); + const { client } = await connect({ roots: () => [plain, broken] }); + + const result = await call(client, 'project_get'); + + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('CONFIG_UNREADABLE'); + expect(text(result)).toContain(path.join(broken, 'formio.json')); + }); + + it('counts a malformed mapping entry as a record and surfaces its error', async () => { + const malformed = workspace('malformed'); + const plain = workspace('plain'); + fs.mkdirSync(path.join(os.homedir(), '.formio'), { recursive: true }); + fs.writeFileSync( + path.join(os.homedir(), '.formio', 'projects.json'), + JSON.stringify({ [malformed]: { env: 'not an object' } }) + ); + const { client } = await connect({ roots: () => [plain, malformed] }); + + const result = await call(client, 'project_get'); + + expect(result.isError).toBe(true); + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('CONFIG_UNREADABLE'); + expect(text(result)).toContain(malformed); + }); + + it('treats two spellings of one directory as one root', async () => { + const app = workspace('app'); + const { client } = await connect({ + listRoots: async () => ({ + roots: [{ uri: `${pathToFileURL(app).href}/` }, { uri: pathToFileURL(app).href }], + }), + }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(app); + expect(result.cwdSource).toBe('client-root'); + }); + + it('ignores a root that is not a file:// URI', async () => { + const app = workspace('app'); + const { client } = await connect({ + listRoots: async () => ({ + roots: [{ uri: 'https://example.com/workspace' }, { uri: pathToFileURL(app).href }], + }), + }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(app); + expect(result.cwdSource).toBe('client-root'); + }); + + it('falls through when every root is something other than a file:// URI', async () => { + const { client } = await connect({ + listRoots: async () => ({ roots: [{ uri: 'https://example.com/workspace' }] }), + }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(SERVER_CWD); + expect(result.cwdSource).toBe('server'); + }); + + it('sends no roots/list to a client that did not declare roots, and uses CLAUDE_PROJECT_DIR', async () => { + const app = workspace('app'); + vi.stubEnv('CLAUDE_PROJECT_DIR', app); + const { client, sentToClient } = await connect(); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(app); + expect(result.cwdSource).toBe('claude-project-dir'); + expect(sentToClient).not.toContain('roots/list'); + }); + + it('ignores a CLAUDE_PROJECT_DIR that is not absolute', async () => { + vi.stubEnv('CLAUDE_PROJECT_DIR', 'relative/dir'); + const { client } = await connect(); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(SERVER_CWD); + expect(result.cwdSource).toBe('server'); + }); + + it("falls back to the server's own directory when nothing else names one", async () => { + const { client } = await connect(); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(SERVER_CWD); + expect(result.cwdSource).toBe('server'); + }); + + it('treats a roots/list that does not answer in time as no roots', async () => { + const app = workspace('app'); + vi.stubEnv('CLAUDE_PROJECT_DIR', app); + const { client } = await connect({ + listRoots: () => new Promise(() => {}), + rootsTimeoutMs: 50, + }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwd).toBe(app); + expect(result.cwdSource).toBe('claude-project-dir'); + }); + + it('treats a roots/list that fails as no roots', async () => { + const { client } = await connect({ + listRoots: async () => { + throw new Error('roots unavailable'); + }, + }); + + const result = report(await call(client, 'project_get')); + + expect(result.cwdSource).toBe('server'); + }); + + it('keeps the last good roots when a later read fails or times out', async () => { + const first = workspace('first'); + const answers: Array<() => Promise<{ roots: Array<{ uri: string }> }>> = [ + async () => ({ roots: [{ uri: pathToFileURL(first).href }] }), + async () => { + throw new Error('roots unavailable'); + }, + () => new Promise(() => {}), + ]; + let calls = 0; + const { client } = await connect({ + listRoots: () => answers[Math.min(calls++, answers.length - 1)](), + rootsTimeoutMs: 50, + }); + + expect(report(await call(client, 'project_get')).cwd).toBe(first); + + await client.sendRootsListChanged(); + expect(report(await call(client, 'project_get')).cwd).toBe(first); + + await client.sendRootsListChanged(); + const afterTimeout = report(await call(client, 'project_get')); + expect(afterTimeout.cwd).toBe(first); + expect(afterTimeout.cwdSource).toBe('client-root'); + expect(calls).toBe(3); + }); + + it('reads again after list_changed when an earlier read failed', async () => { + const later = workspace('later'); + let calls = 0; + const { client } = await connect({ + listRoots: async () => { + calls += 1; + if (calls === 1) { + throw new Error('not yet'); + } + return { roots: [{ uri: pathToFileURL(later).href }] }; + }, + }); + + expect(report(await call(client, 'project_get')).cwdSource).toBe('server'); + + await client.sendRootsListChanged(); + expect(report(await call(client, 'project_get')).cwd).toBe(later); + }); + + it('reads the roots once and again after notifications/roots/list_changed', async () => { + const first = workspace('first'); + const second = workspace('second'); + let roots = [first]; + const { client, rootsAnswered } = await connect({ roots: () => roots }); + + expect(report(await call(client, 'project_get')).cwd).toBe(first); + expect(report(await call(client, 'server_status')).cwd).toBe(first); + expect(rootsAnswered()).toBe(1); + + roots = [second]; + await client.sendRootsListChanged(); + + expect(report(await call(client, 'project_get')).cwd).toBe(second); + expect(rootsAnswered()).toBe(2); + }); +}); + +// The report says where the directory came from, and only the server's own directory +// earns the note telling the agent to pass cwd: that is the one source that is not +// where the user is working. +describe('cwdSource in the project reports', () => { + it.each(['project_get', 'server_status'])( + '%s reports client-root with no note about the server directory', + async (tool) => { + const app = workspace('app', 'https://app.form.io'); + const { client } = await connect({ roots: () => [app] }); + + const result = await call(client, tool); + const payload = report(result); + + expect(payload.cwdSource).toBe('client-root'); + expect(text(result)).not.toMatch(/own working directory/i); + } + ); + + it.each(['project_get', 'server_status'])( + "%s reports server, with the note to pass cwd, from the server's own directory", + async (tool) => { + writeProjectEntry({ cwd: SERVER_CWD, env: { FORMIO_PROJECT_URL: 'https://spawn.form.io' } }); + const { client } = await connect(); + + const result = await call(client, tool); + const payload = report(result); + + expect(payload.cwdSource).toBe('server'); + expect((payload.notes as string[]).join('\n')).toMatch( + /server's own working directory[\s\S]*pass cwd/i + ); + } + ); + + it('adds no note for a cwd argument', async () => { + const app = workspace('app', 'https://app.form.io'); + const { client } = await connect(); + + const result = await call(client, 'project_get', { cwd: app }); + + expect(report(result).cwdSource).toBe('argument'); + expect(text(result)).not.toMatch(/pass cwd/i); + }); + + // CLAUDE_PROJECT_DIR is a launch default, not a directory the caller or the client + // named for this call, so it earns the same note — naming the variable, not the + // server's directory. + it.each(['project_get', 'server_status'])( + '%s reports claude-project-dir with the note to pass cwd', + async (tool) => { + const app = workspace('app', 'https://app.form.io'); + vi.stubEnv('CLAUDE_PROJECT_DIR', app); + const { client } = await connect(); + + const result = await call(client, tool); + const payload = report(result); + + expect(payload.cwdSource).toBe('claude-project-dir'); + const notes = (payload.notes as string[]).join('\n'); + expect(notes).toContain(app); + expect(notes).toMatch(/CLAUDE_PROJECT_DIR[\s\S]*pass cwd/i); + expect(notes).not.toMatch(/server's own working directory/i); + } + ); + + it('names CLAUDE_PROJECT_DIR, without a remedy, when nothing is configured there', async () => { + const app = workspace('app'); + vi.stubEnv('CLAUDE_PROJECT_DIR', app); + const { client } = await connect(); + + const payload = report(await call(client, 'project_get')); + + expect(payload.status).toBe('not-configured'); + expect(payload.remedy).toBeUndefined(); + expect(payload.message).toMatch(/CLAUDE_PROJECT_DIR/); + }); +}); + +// Every tool that resolves a project takes the same order — not only the two that +// report it. A tool on an unconfigured directory fails naming the directory it +// searched, which is the client's root here. +const PROJECT_SCOPED: Record> = { + form_create: { form: { title: 'T', name: 't', path: 't', components: [] } }, + form_get: { formIdOrPath: 'contact' }, + form_list: {}, + form_revision_get: { formIdOrPath: 'contact', version: '1' }, + form_revision_list: { formIdOrPath: 'contact' }, + form_update: { formId: '67890abcdef012345678abcd', form: { components: [] }, note: 'n' }, + form_publish: { formId: '67890abcdef012345678abcd', note: 'n' }, + form_revert: { formId: '67890abcdef012345678abcd', version: '1', note: 'n' }, + project_export: {}, + project_import: { template: {} }, + role_create: { role: { title: 'R' } }, + role_list: {}, + role_update: { roleId: '69d68310040fa2cea2572945', role: { title: 'R' } }, + action_type_list: { formId: '67890abcdef012345678abcd' }, + action_type_get: { formId: '67890abcdef012345678abcd', actionName: 'email' }, + action_create: { + formId: '67890abcdef012345678abcd', + action: { name: 'email', title: 'Email', handler: ['after'], method: ['create'] }, + }, + action_list: { formId: '67890abcdef012345678abcd' }, + action_get: { formId: '67890abcdef012345678abcd', actionId: '69d68310040fa2cea2572945' }, + action_update: { + formId: '67890abcdef012345678abcd', + actionId: '69d68310040fa2cea2572945', + action: { name: 'email', title: 'Email', handler: ['after'], method: ['create'] }, + }, + action_delete: { formId: '67890abcdef012345678abcd', actionId: '69d68310040fa2cea2572945' }, +}; + +describe('every project-resolving tool takes the same order', () => { + it('covers every tool that takes cwd', async () => { + const { client } = await connect(); + const { tools } = await client.listTools(); + const withCwd = tools + .filter((tool) => 'cwd' in (tool.inputSchema.properties ?? {})) + .map((tool) => tool.name) + .sort(); + + expect(withCwd).toEqual( + [...Object.keys(PROJECT_SCOPED), 'project_get', 'project_set', 'server_status'].sort() + ); + }); + + it.each(Object.entries(PROJECT_SCOPED))( + '%s resolves against the client root', + async (tool, args) => { + const app = workspace('scoped'); + const { client } = await connect({ roots: () => [app] }); + + const result = await call(client, tool, args); + + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('NOT_CONFIGURED'); + expect(text(result)).toContain(app); + expect(text(result)).not.toMatch(/own working directory/i); + } + ); + + it.each(Object.entries(PROJECT_SCOPED))( + '%s refuses with INVALID_ARGUMENT when several roots are ambiguous', + async (tool, args) => { + const one = workspace('one', 'https://one.form.io'); + const two = workspace('two', 'https://two.form.io'); + const { client } = await connect({ roots: () => [one, two] }); + + const result = await call(client, tool, args); + + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('INVALID_ARGUMENT'); + expect(text(result)).toContain(one); + expect(text(result)).toContain(two); + } + ); + + it('project_set records the mapping for the client root', async () => { + const app = workspace('app'); + const { client } = await connect({ roots: () => [app] }); + + const result = await call(client, 'project_set', { projectUrl: 'https://rooted.form.io' }); + const payload = report(result); + + expect(payload.cwd).toBe(app); + expect(payload.ok).toBe(true); + expect(readProjectEntry(app)?.env.FORMIO_PROJECT_URL).toBe('https://rooted.form.io'); + expect(readProjectEntry(SERVER_CWD)).toBeNull(); + expect(text(result)).not.toMatch(/own working directory/i); + }); + + it('project_set refuses the ambiguous roots rather than writing under the server directory', async () => { + const one = workspace('one', 'https://one.form.io'); + const two = workspace('two', 'https://two.form.io'); + const { client } = await connect({ roots: () => [one, two] }); + + const result = await call(client, 'project_set', { projectUrl: 'https://rooted.form.io' }); + + expect(result._meta?.[ERROR_META_KEY]?.code).toBe('INVALID_ARGUMENT'); + expect(readProjectEntry(SERVER_CWD)).toBeNull(); + }); + + it('project_set warns when CLAUDE_PROJECT_DIR supplied the directory', async () => { + const app = workspace('app'); + vi.stubEnv('CLAUDE_PROJECT_DIR', app); + const { client } = await connect(); + + const result = await call(client, 'project_set', { projectUrl: 'https://launched.form.io' }); + + expect(report(result).cwd).toBe(app); + expect(text(result)).toMatch(/warning/i); + expect(text(result)).toContain('CLAUDE_PROJECT_DIR'); + expect(readProjectEntry(app)?.env.FORMIO_PROJECT_URL).toBe('https://launched.form.io'); + }); + + it('project_set keeps warning when it falls back to the server directory', async () => { + const { client } = await connect(); + + const result = await call(client, 'project_set', { projectUrl: 'https://spawned.form.io' }); + + expect(report(result).cwd).toBe(SERVER_CWD); + expect(text(result)).toMatch(/own working directory/i); + }); +}); diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts index 9aee1680..6d12de95 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -3,12 +3,33 @@ import { browserlessReason, currentBrowserEnvironment } from './browser-availabi import { openInBrowser } from './browser-launch.js'; import { ResolvedFormioConfig } from './config.js'; import { formioRawFetch } from './formio-client.js'; +import { ToolError } from './tool-errors.js'; export interface AuthenticateOptions { onReady?: (port: number) => void; } const DEFAULT_AUTH_HOST = '127.0.0.1'; + +// A login that cannot complete — no browser, or none before the timeout — leaves the +// call without a credential: AUTH_REQUIRED, with the message saying which and its remedy. +function loginFailed(message: string): ToolError { + return new ToolError({ code: 'AUTH_REQUIRED', message }); +} + +/** + * A login form that could not be resolved keeps the code of what went wrong — a + * deployment that did not answer is NETWORK_ERROR, not a missing credential — and + * anything that is not a ToolError is INTERNAL. + */ +export function loginFormFailed(error: unknown): ToolError { + const reason = error instanceof Error ? error.message : String(error); + return new ToolError({ + code: error instanceof ToolError ? error.code : 'INTERNAL', + message: `Could not resolve the Form.io login form: ${reason}`, + cause: error, + }); +} // Generous on purpose. The point of the timeout is to stop an unattended hang // from lasting forever, not to hurry an interactive login — an SSO redirect, a // 2FA prompt or a password-manager detour can all take minutes. Anyone actually @@ -209,7 +230,7 @@ function assertBrowserAvailable(config: ResolvedFormioConfig): void { if (!reason) { return; } - throw new Error( + throw loginFailed( `Cannot complete the Form.io browser login: ${reason}. ` + apiKeyRemedy(config) + `If the host running your browser can reach this machine, set both FORMIO_AUTH_HOST=0.0.0.0 and ` + @@ -241,7 +262,7 @@ export async function authenticate( } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(500).send(`Login form resolution failed: ${message}`); - rejectJwt(err instanceof Error ? err : new Error(message)); + rejectJwt(loginFormFailed(err)); } }); @@ -268,11 +289,15 @@ export async function authenticate( // A fixed FORMIO_AUTH_PORT that is already taken is the usual one; ignoring // it would leave the caller waiting out the whole login timeout. if (err) { + // Not a credential problem: the server could not start, so the cause is named. rejectJwt( - new Error( - `Could not start the Form.io login server on ${host}:${config.authPort ?? 0}: ${err.message}. ` + - `Free that port, or set FORMIO_AUTH_PORT to one that is available.` - ) + new ToolError({ + code: 'INTERNAL', + message: + `Could not start the Form.io login server on ${host}:${config.authPort ?? 0}: ${err.message}. ` + + `Free that port, or set FORMIO_AUTH_PORT to one that is available.`, + cause: err, + }) ); return; } @@ -301,7 +326,7 @@ export async function authenticate( const timeoutMs = config.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS; const timer = setTimeout(() => { rejectJwt( - new Error( + loginFailed( `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the Form.io login to complete. ` + (loginUrl ? `Open ${loginUrl} to sign in, then retry. ` : '') + `If this environment has no browser — a container, a remote shell, or CI: ` + diff --git a/packages/mcp-server/src/cli-launch.ts b/packages/mcp-server/src/cli-launch.ts index ed1d4754..b9f7fc59 100644 --- a/packages/mcp-server/src/cli-launch.ts +++ b/packages/mcp-server/src/cli-launch.ts @@ -19,6 +19,9 @@ function readPackageVersion(): string { export const SERVER_VERSION = readPackageVersion(); +// The name this server reports at initialize and in server_status. +export const SERVER_NAME = 'formio-mcp'; + export const SERVER_PACKAGE = '@formio/mcp'; // How every message that names a shell command spells the CLI. diff --git a/packages/mcp-server/src/committed-config.ts b/packages/mcp-server/src/committed-config.ts index 0c7bfafc..328244ab 100644 --- a/packages/mcp-server/src/committed-config.ts +++ b/packages/mcp-server/src/committed-config.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { normalizeHttpUrl } from './config.js'; +import { ToolError } from './tool-errors.js'; export const COMMITTED_CONFIG_FILE = 'formio.json'; @@ -22,7 +23,7 @@ export interface CommittedProjectConfig { // caller to project_set, which writes a mapping this file then shadows. The // symptom clears, the cause persists, and the precedence order hides it. Same // reasoning as ProjectMapUnreadableError, one layer up. -export class CommittedConfigUnusableError extends Error { +export class CommittedConfigUnusableError extends ToolError { // Carried as a field, not only inside the message, so a caller that acts on the // exact path the walk rejected does not have to parse it back out of prose. readonly filePath: string; @@ -35,12 +36,14 @@ export class CommittedConfigUnusableError extends Error { // landing on such a file either claims it or refuses with nothing runnable to // offer. An edit is an instruction both a human with an editor and an agent with // file tools can carry out. - super( - `The committed Form.io configuration at ${filePath} cannot be used: ${reason}. ` + + super({ + code: 'CONFIG_UNREADABLE', + message: + `The committed Form.io configuration at ${filePath} cannot be used: ${reason}. ` + `Fix that file — it takes precedence over the working-directory mapping, so writing a mapping will not override it. ` + `A usable one is a JSON object holding {"projectUrl": ""}, plus "baseUrl" only when the deployment cannot be derived from the project URL. ` + - `Edit it directly (ask the user first — this server does not own a committed file), or remove it to let the working-directory mapping govern.` - ); + `Edit it directly (ask the user first — this server does not own a committed file), or remove it to let the working-directory mapping govern.`, + }); this.name = 'CommittedConfigUnusableError'; this.filePath = filePath; } diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts index 4fe5d35c..ade36bd6 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -1,3 +1,4 @@ +import { ToolError } from './tool-errors.js'; export const DEFAULT_BASE_URL = 'https://api.form.io'; /** @@ -83,9 +84,9 @@ export function stripTrailingSlashes(url: string): string { * refusals on exit 1. A malformed value read from disk is a different thing entirely * and stays "could not answer", because no answer from the user fixes a broken record. */ -export class InvalidRequestedUrlError extends Error { +export class InvalidRequestedUrlError extends ToolError { constructor(message: string) { - super(message); + super({ code: 'INVALID_ARGUMENT', message }); this.name = 'InvalidRequestedUrlError'; } } @@ -208,7 +209,7 @@ export function getConfig(): FormioConfig { authHost: process.env.FORMIO_AUTH_HOST || undefined, authPort: parsePositiveInt(process.env.FORMIO_AUTH_PORT), authTimeoutMs: toMilliseconds(parsePositiveInt(process.env.FORMIO_AUTH_TIMEOUT)), - forceBrowser: process.env.FORMIO_FORCE_BROWSER === '1', + forceBrowser: readBooleanEnv(process.env.FORMIO_FORCE_BROWSER), }; } @@ -251,6 +252,17 @@ export function readHttpUrlEnv({ } } +/** + * The one reading of a boolean environment variable: after trimming, `true` or `1` + * in any case is true, and every other value — unset included — is false. Shared + * so that every such variable accepts the same spellings; a value that works for + * one and is silently ignored by another is a setting nobody can rely on. + */ +export function readBooleanEnv(raw: string | undefined): boolean { + const normalized = (raw ?? '').trim().toLowerCase(); + return normalized === 'true' || normalized === '1'; +} + function parsePositiveInt(raw: string | undefined): number | undefined { if (!raw) { return undefined; diff --git a/packages/mcp-server/src/formio-client.ts b/packages/mcp-server/src/formio-client.ts index 0c4dfc21..43ba8e0b 100644 --- a/packages/mcp-server/src/formio-client.ts +++ b/packages/mcp-server/src/formio-client.ts @@ -1,21 +1,12 @@ -import { FormioConfig, ResolvedFormioConfig } from './config.js'; +import { FormioConfig, readBooleanEnv, ResolvedFormioConfig } from './config.js'; import { getAuthHeader } from './auth-header.js'; import { ensureAuthenticated, invalidateJwtCache } from './ensure-auth.js'; import { clearToken } from './token-cache.js'; import { requireBaseUrl } from './project-resolver.js'; +import { FormioApiError, FormioNetworkError, ToolError } from './tool-errors.js'; -// Accept the common truthy spellings ("true", "TRUE", "1") so a self-signed -// deployment (e.g. a local Form.io Enterprise server) is not rejected over a -// trivial casing/format mismatch in the env value. -export function isInsecureTlsEnabled(value: string | undefined): boolean { - if (!value) { - return false; - } - const normalized = value.trim().toLowerCase(); - return normalized === 'true' || normalized === '1'; -} - -if (isInsecureTlsEnabled(process.env.FORMIO_INSECURE_TLS)) { +// For a self-signed deployment, such as a local Form.io Enterprise server. +if (readBooleanEnv(process.env.FORMIO_INSECURE_TLS)) { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } @@ -30,18 +21,86 @@ export interface FormioFetchOptions { body?: unknown; responseType?: 'text' | 'json'; signal?: AbortSignal; + /** Return `{ data, total }`, reading the total from an index route's Content-Range. */ + withMeta?: boolean; +} + +/** One page of an index route: the body, and the collection size Form.io reported. */ +export interface FormioPage { + data: unknown; + /** Absent when the response reports no total. */ + total?: number; +} + +// Form.io's index routes report the collection size as `{from}-{to}/{total}`, or +// `*/{total}` for an empty page; a total of `*` means it is not known. +function contentRangeTotal(header: string | null | undefined): number | undefined { + const match = header?.trim().match(/^(?:items\s+)?(?:\d+-\d+|\*)\/(\d+)$/); + return match ? Number(match[1]) : undefined; +} + +// A `skip` at or past the total is answered 416 with `*/{total}`: that is an empty +// page past the end, not a failure. Any other error status stays an error. +async function readPage(response: Response, url: URL): Promise { + const total = contentRangeTotal(response.headers?.get('content-range')); + if (response.status === 416 && total !== undefined) { + return { data: [], total }; + } + if (!response.ok) { + throw await apiError(response, url); + } + return { data: await response.json(), total }; +} + +interface ReadResponseRequest { + response: Response; + url: URL; + options?: FormioFetchOptions; +} + +async function readResponse({ response, url, options }: ReadResponseRequest): Promise { + if (options?.withMeta) { + return readPage(response, url); + } + if (!response.ok) { + throw await apiError(response, url); + } + return options?.responseType === 'text' ? response.text() : response.json(); } // A redirect is reported rather than followed: fetch would re-send the request, with // its x-jwt-token or x-token header, to wherever the Location header points. -function formatApiError(response: Response, url: URL): string { +async function apiError(response: Response, url: URL): Promise { const location = response.status >= 300 && response.status < 400 ? response.headers?.get('location') : null; - return `Form.io API error: ${response.status} | URL: ${url.toString()}${ - location - ? ` | Redirected to ${location}, which is not followed: every request addresses the resolved project directly.` - : '' - }`; + return new FormioApiError({ + status: response.status, + url: url.toString(), + body: await readBody(response), + location: location ?? undefined, + }); +} + +// The body explains the failure, but reading it can fail too (a dropped connection, +// a stream already consumed); the status alone is still worth reporting then. +async function readBody(response: Response): Promise { + try { + return await response.text(); + } catch { + return undefined; + } +} + +/** + * `fetch`, with a request that received no response raised as a FormioNetworkError + * naming the cause and the URL — the one wrapper every Form.io request goes through. + */ +export async function send(url: URL, init: RequestInit): Promise { + try { + return await fetch(url, init); + } catch (cause) { + throw new FormioNetworkError({ url: url.toString(), cause }); + } } function buildFetchInit(config: FormioConfig, options?: FormioFetchOptions): RequestInit { @@ -69,32 +128,27 @@ export async function formioRawFetch( config: ResolvedFormioConfig, options?: FormioFetchOptions ): Promise { - const response = await fetch(url, buildFetchInit(config, options)); - const parseResponse = (res: Response) => - options?.responseType === 'text' ? res.text() : res.json(); - - if (!response.ok) { - if (response.status === 401 && config.jwt) { - // A jwt in hand means the auth path already ran, which means it already - // demanded a base URL — so this is a re-read of a resolved value, not a - // new requirement. Funnelled through the same guard rather than asserted, - // so an unreachable state raises the actionable error instead of keying - // the cache under "undefined". - const baseUrl = requireBaseUrl(config); - invalidateJwtCache(baseUrl); - await clearToken(baseUrl); - config.jwt = undefined; - await ensureAuthenticated(config); - const retryResponse = await fetch(url, buildFetchInit(config, options)); - if (!retryResponse.ok) { - throw new Error(formatApiError(retryResponse, url)); - } - return parseResponse(retryResponse); - } - throw new Error(formatApiError(response, url)); + const response = await send(url, buildFetchInit(config, options)); + + if (response.status === 401 && config.jwt) { + // A jwt in hand means the auth path already ran, which means it already + // demanded a base URL — so this is a re-read of a resolved value, not a + // new requirement. Funnelled through the same guard rather than asserted, + // so an unreachable state raises the actionable error instead of keying + // the cache under "undefined". + const baseUrl = requireBaseUrl(config); + invalidateJwtCache(baseUrl); + await clearToken(baseUrl); + config.jwt = undefined; + await ensureAuthenticated(config); + return readResponse({ + response: await send(url, buildFetchInit(config, options)), + url, + options, + }); } - return parseResponse(response); + return readResponse({ response, url, options }); } // Whether a built URL addresses the project: the same origin, and a path that IS @@ -108,6 +162,18 @@ function isUnderProject(url: URL, projectUrl: URL): boolean { ); } +export function formioFetch( + path: string, + params: Record, + config: ResolvedFormioConfig, + options: FormioFetchOptions & { withMeta: true } +): Promise; +export function formioFetch( + path: string, + params: Record, + config: ResolvedFormioConfig, + options?: FormioFetchOptions +): Promise; export async function formioFetch( path: string, params: Record, @@ -128,9 +194,10 @@ export async function formioFetch( // reaches a credential. The tool-argument rule refuses these values first and names // the argument; this holds for every caller, including ones that bypass it. if (!isUnderProject(url, new URL(base))) { - throw new Error( - `Refusing the Form.io request for path ${JSON.stringify(path)}: it resolves to ${url.origin}${url.pathname}, which is not under the Project URL ${config.projectUrl}. Every request addresses the project this directory resolves to.` - ); + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: `Refusing the Form.io request for path ${JSON.stringify(path)}: it resolves to ${url.origin}${url.pathname}, which is not under the Project URL ${config.projectUrl}. Every request addresses the project this directory resolves to.`, + }); } await ensureAuthenticated(config); diff --git a/packages/mcp-server/src/mcp-responses.ts b/packages/mcp-server/src/mcp-responses.ts index 6e782b06..e6951804 100644 --- a/packages/mcp-server/src/mcp-responses.ts +++ b/packages/mcp-server/src/mcp-responses.ts @@ -1,3 +1,5 @@ +import { toolErrorDetails } from './tool-errors.js'; + export function toMcpTextResult(data: unknown) { return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }], @@ -23,14 +25,37 @@ export function toMcpStructuredResult(structured: Record, text? }; } -// `notes` lead the message for the same reason a successful answer's do: a note is -// often the CAUSE of the failure being reported — an ignored formio.json on the walk, -// an environment variable a host never expanded — and a failure rendered alone hides -// the first half of the story. +// Where the structured half of an error travels. Not structuredContent: MCP clients +// validate structuredContent against the tool's (success) outputSchema even when +// isError is set, so an error payload there is rejected as a protocol error. +export const ERROR_META_KEY = 'io.form/error'; + +// `[CODE]` leads the text so a client that surfaces only text still shows the code. +// `notes` follow it and lead the message for the same reason a successful answer's +// do: a note is often the CAUSE of the failure being reported — an ignored +// formio.json on the walk, an environment variable a host never expanded — and a +// failure rendered alone hides the first half of the story. export function toMcpError(error: unknown, notes: readonly string[] = []) { const message = error instanceof Error ? error.message : String(error); + const details = toolErrorDetails(error); + const text = [...notes, message].filter(Boolean).join('\n'); return { - content: [{ type: 'text' as const, text: [...notes, message].filter(Boolean).join('\n') }], + content: [{ type: 'text' as const, text: `[${details.code}] ${text}` }], + _meta: { [ERROR_META_KEY]: details }, isError: true, }; } + +/** + * A tool handler whose every throw becomes a coded error result, for a handler too + * long to wrap in its own try/catch without obscuring it. + */ +export function catchToolErrors(handler: (args: A) => Promise) { + return async (args: A): Promise> => { + try { + return await handler(args); + } catch (error) { + return toMcpError(error); + } + }; +} diff --git a/packages/mcp-server/src/output-schemas.ts b/packages/mcp-server/src/output-schemas.ts index dbd38515..a610890a 100644 --- a/packages/mcp-server/src/output-schemas.ts +++ b/packages/mcp-server/src/output-schemas.ts @@ -8,209 +8,150 @@ * an exhaustiveness the API does not have and would start rejecting valid * responses the day a field is added upstream. * - * Shapes (plain objects of zod types) are what `registerTool` accepts for - * `outputSchema`; `*Document` values are the same content as a schema, for - * nesting inside arrays. + * Openness holds at EVERY level, the top level included. Handed a raw shape, + * the SDK wraps it in a closed object and publishes `additionalProperties: false`, + * so each tool's `outputSchema` is passed as a `z.looseObject` instead — never a + * raw shape, and never a `z.object` anywhere inside one. Fields Form.io stores as + * Mixed are typed `unknown`: Mixed holds any value, `null` included. */ import { z } from 'zod'; +import { CWD_SOURCES } from './workspace-directory.js'; + +// Field descriptions are kept only on the fields a caller branches on — `ok`, `status`, +// `total`, `hasMore` and `remedy` — because every description here is repeated on every +// tool that returns the document. Every other field is typed and left undescribed: what +// a Form.io field means is the formio-schema skill's to document, and what a project +// report field means is in the server instructions and the report's own `message`. +// +// A Form.io document's schema declares only the fields a caller acts on — the ones the +// skills and the tools read. Every schema is open, so the fields Form.io stores beside +// them (`created`, `owner`, `settings`, `machineName`, …) still pass through untouched; +// declaring them would only lengthen every tool that returns the document. + +const looseList = z.array(z.looseObject({})); +// A Mongoose Mixed field: any value, `null` included. +const mixed = z.unknown().optional(); + +// `access` and `submissionAccess` are declared because the auth skills read them from +// form_get before editing them. They are typed as Mixed rather than as the array (or +// `null`) Form.io stores: the full shape cost more than the rest of the document, on +// every tool that returns a form, and the formio-schema skill documents it. +export const formDocument = z.looseObject({ + _id: z.string().optional(), + title: z.string().optional(), + name: z.string().optional(), + path: z.string().optional(), + type: z.string().optional(), + display: z.string().optional(), + components: looseList.optional(), + access: mixed, + submissionAccess: mixed, + // "original", "current", or empty/false when history is off. + revisions: z.union([z.string(), z.boolean()]).optional(), +}); -const identity = { - _id: z.string().optional().describe('Form.io document ID (24-character hex)'), - created: z.string().optional().describe('ISO 8601 creation timestamp'), - modified: z.string().optional().describe('ISO 8601 last-modified timestamp'), - machineName: z.string().optional().describe('Machine-readable unique name within the project'), -}; - -export const formShape = { - ...identity, - title: z.string().optional().describe('Human-readable form title'), - name: z.string().optional().describe('API name of the form'), - path: z.string().optional().describe('URL path the form is served at, relative to the project'), - type: z.string().optional().describe('"form" for a form, "resource" for a data resource'), - display: z.string().optional().describe('Render style: "form" or "wizard"'), - components: z - .array(z.looseObject({})) - .optional() - .describe('Component tree defining the fields, layout, and logic of the form'), - tags: z.array(z.string()).optional().describe('Tags applied to the form'), - access: z - .array(z.looseObject({})) - .optional() - .describe('Role-based access to the form definition itself'), - submissionAccess: z - .array(z.looseObject({})) - .optional() - .describe('Role-based access to the submissions of this form'), - revisions: z - .union([z.string(), z.boolean()]) - .optional() - .describe('Revision mode: "original", "current", or false when disabled'), - settings: z.looseObject({}).optional().describe('Form-level settings'), - properties: z.looseObject({}).optional().describe('Custom key/value properties'), - project: z.string().optional().describe('ID of the project owning the form'), - owner: z.string().nullish().describe('ID of the submission owner, when set'), -}; - -export const formDocument = z.looseObject(formShape); - -export const roleShape = { - ...identity, - title: z.string().optional().describe('Human-readable role title'), - description: z.string().optional().describe('What the role is for'), - admin: z.boolean().optional().describe('Whether the role grants administrative access'), - default: z - .boolean() - .optional() - .describe('Whether the role is assigned to anonymous or newly registered users'), - project: z.string().optional().describe('ID of the project owning the role'), -}; +export const roleDocument = z.looseObject({ + _id: z.string().optional(), + title: z.string().optional(), + admin: z.boolean().optional(), + default: z.boolean().optional(), +}); -export const roleDocument = z.looseObject(roleShape); +export const actionDocument = z.looseObject({ + _id: z.string().optional(), + name: z.string().optional(), + title: z.string().optional(), + form: z.string().optional(), + handler: z.array(z.string()).optional(), + method: z.array(z.string()).optional(), + priority: z.number().optional(), + condition: mixed, + settings: mixed, +}); -export const actionShape = { - ...identity, - name: z.string().optional().describe('Action type name, e.g. "save", "login", "email"'), - title: z.string().optional().describe('Human-readable action title'), - form: z.string().optional().describe('ID of the form the action runs on'), - handler: z - .array(z.string()) - .optional() - .describe('When the action runs relative to submission handling: "before" and/or "after"'), - method: z - .array(z.string()) - .optional() - .describe('Submission methods the action responds to, e.g. "create", "update"'), - priority: z.number().optional().describe('Execution order; higher runs first'), - condition: z.looseObject({}).optional().describe('Condition gating whether the action runs'), - settings: z - .looseObject({}) - .optional() - .describe('Action-type-specific settings; see action_type_get for the schema'), +// `name` is what action_create and action_type_get take. +const actionTypeFields = { + name: z.string().optional(), + title: z.string().optional(), }; -export const actionDocument = z.looseObject(actionShape); +export const actionTypeDocument = z.looseObject(actionTypeFields); -export const actionTypeDocument = z.looseObject({ - name: z.string().optional().describe('Action type name to pass as `name` to action_create'), - title: z.string().optional().describe('Human-readable action type title'), - description: z.string().optional().describe('What the action type does'), - priority: z.number().optional().describe('Default execution priority'), - defaults: z.looseObject({}).optional().describe('Default settings applied on creation'), +export const actionTypeInfoDocument = z.looseObject({ + ...actionTypeFields, + // Its components are the keys valid in `settings` on action_create. + settingsForm: z.looseObject({}).optional(), }); -export const actionTypeInfoShape = { - name: z.string().optional().describe('Action type name to pass as `name` to action_create'), - title: z.string().optional().describe('Human-readable action type title'), - description: z.string().optional().describe('What the action type does'), - priority: z.number().optional().describe('Default execution priority'), - defaults: z.looseObject({}).optional().describe('Default settings applied on creation'), - settingsForm: z - .looseObject({}) - .optional() - .describe( - 'Form definition describing the settings this action type accepts; its components are the keys valid in `settings` on action_create' - ), - access: z.looseObject({}).optional().describe('Access requirements for configuring the action'), -}; - export const revisionSummaryDocument = z.looseObject({ - _vid: z - .union([z.string(), z.number()]) - .optional() - .describe('Revision number; pass as `version` to form_revision_get'), - _id: z.string().optional().describe('Revision document ID'), - _vnote: z.string().optional().describe('Note recorded with the revision'), - modified: z.string().optional().describe('ISO 8601 timestamp the revision was published'), - user: z - .union([z.string(), z.looseObject({})]) - .nullish() - .describe('User who published the revision'), + // The revision number: what form_revision_get and form_revert take as `version`. + _vid: z.union([z.string(), z.number()]).optional(), + _id: z.string().optional(), + _vnote: z.string().optional(), + _vuser: z.string().nullish(), + created: z.string().optional(), }); -export const templateShape = { - title: z.string().optional().describe('Project title'), - name: z.string().optional().describe('Project machine name'), - version: z.string().optional().describe('Template schema version'), - description: z.string().optional().describe('Project description'), - roles: z.looseObject({}).optional().describe('Roles, keyed by machine name'), - resources: z.looseObject({}).optional().describe('Resource forms, keyed by machine name'), - forms: z.looseObject({}).optional().describe('Forms, keyed by machine name'), - actions: z.looseObject({}).optional().describe('Actions, keyed by machine name'), -}; +export const templateDocument = z.looseObject({ + roles: z.looseObject({}).optional(), + resources: z.looseObject({}).optional(), + forms: z.looseObject({}).optional(), + actions: z.looseObject({}).optional(), +}); // List payloads are wrapped in a named field because structuredContent must be an -// object, and `count` is stated rather than left to be derived so a caller can see -// at a glance whether a page was truncated by `limit`. -export const formsListShape = { - forms: z.array(formDocument).describe('Matching forms, in the requested sort order'), - count: z.number().describe('Number of forms returned by this call'), -}; +// object. `total` is the collection size Form.io reports, not the page length, and +// is left out when Form.io reports none rather than guessed from the page. +function listOutput(key: string, item: z.ZodType) { + return z.looseObject({ + [key]: z.array(item), + total: z + .number() + .optional() + .describe( + 'Items Form.io holds for this query, across all pages; absent when Form.io does not report one' + ), + hasMore: z.boolean().describe('True when items remain; fetch them with a larger `skip`'), + }); +} -export const rolesListShape = { - roles: z.array(roleDocument).describe('Roles defined in the project'), - count: z.number().describe('Number of roles returned'), -}; +export const formsListOutput = listOutput('forms', formDocument); -export const actionsListShape = { - actions: z.array(actionDocument).describe('Actions configured on the form'), - count: z.number().describe('Number of actions returned'), -}; +export const rolesListOutput = listOutput('roles', roleDocument); -export const actionTypesListShape = { - actionTypes: z - .array(actionTypeDocument) - .describe('Action types this deployment supports for the form'), - count: z.number().describe('Number of action types returned'), -}; +export const actionsListOutput = listOutput('actions', actionDocument); -export const revisionsListShape = { - revisions: z - .array(revisionSummaryDocument) - .describe('Published revision summaries, newest first'), - count: z.number().describe('Number of revisions returned'), -}; +export const actionTypesListOutput = listOutput('actionTypes', actionTypeDocument); + +export const revisionsListOutput = listOutput('revisions', revisionSummaryDocument); /** For tools whose only meaningful answer is "it worked". */ -export const acknowledgementShape = { - ok: z.boolean().describe('True when the operation completed'), - message: z.string().describe('Human-readable result detail'), +const acknowledgementShape = { + ok: z.boolean(), + message: z.string(), }; -export const projectMappingShape = { +export const acknowledgementOutput = z.looseObject(acknowledgementShape); + +export const projectMappingOutput = z.looseObject({ ...acknowledgementShape, - // Overridden: for a writer, "it worked" is not "the write reached disk" — a record - // can land and leave the directory no more usable than before, which is the answer - // the caller has to act on. + // For a writer, "it worked" is not "the write reached disk": a record can land and + // leave the directory no more usable than before, which is the answer the caller acts + // on. `projectUrl` is the one the next call targets, which under a committed + // formio.json is the one that file names; `changed` is false when the mapping was + // already in place; `forced` marks a pair recorded with `project set --force`. ok: z .boolean() .describe( - 'True when the directory is ready for a deployment call. False means the record WAS written and the directory still resolves no Base URL, because a committed formio.json governs it and supplies none — `message` then carries the report naming the file and the key to add. Do not retry this call; make that edit' + 'False: the record WAS written but the directory still needs attention; do what `message` names, and do not retry this call' ), - cwd: z.string().describe('Working directory the mapping is keyed against'), - projectUrl: z - .string() - .describe( - 'Project URL now ACTIVE for that directory — what the next tool call will target. Usually the project this call just recorded; where a committed formio.json governs the directory, it is the project THAT file names, because the mapping written here takes effect only if that file goes away. The message says so when the two differ' - ), - baseUrl: z - .string() - .optional() - .describe( - "The deployment that serves `projectUrl`, from the same record — never one record's project beside another record's deployment" - ), - changed: z - .boolean() - .describe( - 'False when the requested mapping was already in place and nothing was written. True means the RECORD changed, which is not necessarily the same as the ACTIVE project changing — a committed formio.json still outranks it' - ), - forced: z - .boolean() - .optional() - .describe( - 'True when the pair that now resolves was recorded with `project set --force`, which waives the rules tying a Project URL to the deployment that serves it. Set only by that shell command — this tool cannot force a pair, and an unforced call that leaves both halves untouched keeps an existing override rather than silently dropping it. It explains a pair the rules would refuse; it is not a reason to re-record it' - ), -}; + cwd: z.string(), + projectUrl: z.string(), + baseUrl: z.string().optional(), + changed: z.boolean(), + forced: z.boolean().optional(), +}); /** * What `project_get` reports: the resolved configuration, where each half came @@ -221,67 +162,54 @@ export const projectMappingShape = { * contract. `ok` is the only status with both URLs; `base-url-unresolved` has a * project and no deployment; `not-configured` has neither. */ -export const projectResolutionShape = { - status: z - .enum(['ok', 'not-configured', 'base-url-unresolved']) - .describe( - 'Which of the three answers this is: "ok" — both URLs resolved; "not-configured" — nothing is mapped for this directory, so ask the user for a Project URL and record it with project_set; "base-url-unresolved" — the project is recorded and its deployment could not be derived, so ask for the Base URL alone and record it with project_set. Treat anything other than "ok" as blocking.' - ), - cwd: z.string().describe('Working directory the resolution was performed for'), - projectUrl: z - .string() - .optional() - .describe('Project URL that resolves for that directory; absent when status is not-configured'), - baseUrl: z - .string() - .optional() - .describe('Deployment hosting that project; absent unless status is ok'), - projectUrlSource: z - .string() - .optional() - .describe( - 'Which layer supplied the project URL: committed (a formio.json found by walking up), mapping (the per-directory record project_set writes), or environment (FORMIO_PROJECT_URL, the weakest source)' - ), - baseUrlSource: z - .string() - .optional() - .describe( - 'Which layer supplied the base URL: committed, mapping, environment, derived (read off the project URL), or unresolved' - ), - forced: z - .boolean() - .optional() - .describe( - 'True when this pair was recorded with `project set --force`, which waives the rules tying a Project URL to the deployment that serves it — the case those rules cannot tell from a mistake is an internal deployment served from a *.form.io domain. Present only on a resolved pair, and only ever set by that shell command: no tool call and no environment variable grants it. It explains a pair that would otherwise look wrong; it is not a reason to distrust the answer' - ), - shadowed: z - .array(z.string()) - .describe('Layers that could have supplied a URL and were overridden, in precedence order'), - unpaired: z - .array(z.string()) - .describe( - 'Values that were overridden by nothing: a deployment recorded with no project beside it, so nothing says which project it serves and it cannot be read. Separate from `shadowed` because the fix differs — a shadowed value is in the wrong record, an unpaired one is in an incomplete record' - ), - message: z.string().describe('The full human-readable report, including what to do next'), +const resolutionStatus = z.enum(['ok', 'not-configured', 'base-url-unresolved']); + +// `cwdSource` is where the directory came from, and when to pass cwd on later calls +// because of it is the server instructions' rule. `projectUrl` is absent when status is +// not-configured and `baseUrl` unless it is ok; `message` is the full report, including +// what to do next. +const resolutionFields = { + cwd: z.string(), + cwdSource: z.enum(CWD_SOURCES), + projectUrl: z.string().optional(), + baseUrl: z.string().optional(), + projectUrlSource: z.string().optional(), + baseUrlSource: z.string().optional(), + message: z.string(), +}; + +export const projectResolutionOutput = z.looseObject({ + status: resolutionStatus.describe( + '"ok": proceed. "not-configured": ask the user for a Project URL and record it with project_set. "base-url-unresolved": ask for the Base URL alone and do what the report names. Anything but "ok" blocks.' + ), + ...resolutionFields, + // `forced` marks a pair recorded with `project set --force`, not a reason to distrust + // it; `shadowed` lists layers a narrower one overrode; `unpaired` lists deployments + // recorded with no project beside them; `notes` holds anything set aside. + forced: z.boolean().optional(), + shadowed: z.array(z.string()), + unpaired: z.array(z.string()), remedy: z - .object({ - tool: z.string().describe('The tool to call — always project_set'), - arguments: z - .record(z.string(), z.string()) - .describe( - 'Arguments this report already knows: the cwd, and the project where one applies' - ), - supply: z - .array(z.string()) - .describe('The arguments to ask the USER for — one value, added to the arguments above'), + .looseObject({ + tool: z.string(), + arguments: z.record(z.string(), z.string()), + supply: z.array(z.string()).describe('Arguments to ask the user for'), }) .optional() .describe( - 'The same remedy the message states, as a call: ask the user for the argument named in `supply`, add it to `arguments`, and call the tool. Absent when the status is "ok" — and absent when no call fixes the state: a deployment missing from a committed formio.json is recorded by editing that file, whose path and key the message names, because this server never writes a committed file. Acting on this rather than parsing the message is what keeps a caller from composing a call the server would refuse — which write reaches the record holding the project depends on that record' - ), - notes: z - .array(z.string()) - .describe( - 'Anything set aside while resolving — an unreadable mapping a committed formio.json made irrelevant, a stored value that is not a URL, or the directory this answer is about when no cwd was passed. A URL the server dropped as unusable at startup is on its stderr rather than here, because it was never a candidate for this resolution' + 'The fix as a call: ask the user for `supply`, add it to `arguments`, call `tool`. Absent when status is "ok" or the fix is an edit to a committed formio.json' ), -}; + notes: z.array(z.string()), +}); + +/** + * What `server_status` reports: which server is answering, and the same resolution + * project_get reports for the caller's directory. Only the fields a caller branches + * on are documented here; the rest of project_get's report passes through. + */ +export const serverStatusOutput = z.looseObject({ + name: z.string(), + version: z.string(), + status: resolutionStatus.describe("As project_get's `status`"), + ...resolutionFields, +}); diff --git a/packages/mcp-server/src/project-map.ts b/packages/mcp-server/src/project-map.ts index 905926aa..bd2f5be4 100644 --- a/packages/mcp-server/src/project-map.ts +++ b/packages/mcp-server/src/project-map.ts @@ -3,6 +3,7 @@ import path from 'path'; import os from 'os'; import { projectCommand } from './cli-launch.js'; import { readHttpUrlEnv } from './config.js'; +import { ToolError } from './tool-errors.js'; const DEFAULT_CACHE_DIR = path.join(os.homedir(), '.formio'); const PROJECTS_FILE = 'projects.json'; @@ -31,7 +32,7 @@ type ProjectMap = Record; // made every directory look unmapped, and the documented recovery — interview, // then project_set — wrote a fresh single-entry file over every other mapping. // Callers get a distinguishable failure so the file survives for repair. -export class ProjectMapUnreadableError extends Error { +export class ProjectMapUnreadableError extends ToolError { /** The file and cause, so a caller on a path with a DIFFERENT remedy can say its own. */ readonly filePath: string; readonly reason: string; @@ -56,7 +57,10 @@ export class ProjectMapUnreadableError extends Error { ? `Only this directory's entry is affected and the rest of the file is intact, so do NOT delete it — that would discard every other directory's mapping. Replace the entry instead: ${remap}.` : `Repair or delete that file FIRST — no command can write it while it cannot be read, so nothing below will run until it is. Then ${remap}.`; const reason = cause instanceof Error ? cause.message : String(cause); - super(`Cannot read the Form.io project map at ${filePath}: ${reason}. ` + how); + super({ + code: 'CONFIG_UNREADABLE', + message: `Cannot read the Form.io project map at ${filePath}: ${reason}. ` + how, + }); this.name = 'ProjectMapUnreadableError'; this.filePath = filePath; this.reason = reason; diff --git a/packages/mcp-server/src/project-report.ts b/packages/mcp-server/src/project-report.ts index d728468c..553c8bc1 100644 --- a/packages/mcp-server/src/project-report.ts +++ b/packages/mcp-server/src/project-report.ts @@ -1,3 +1,4 @@ +import { SERVER_DIRECTORY } from './workspace-directory.js'; import { COMMITTED_CONFIG_FILE } from './committed-config.js'; import { BASE_URL_UNDERIVABLE, @@ -157,17 +158,20 @@ export interface ProjectReportRequest { */ notes: string[]; /** - * Whether `cwd` above is the directory the CALLER named, or a fallback. + * Whether `cwd` above is a directory something NAMED — the caller, the client's + * workspace root, CLAUDE_PROJECT_DIR — or the server's own fallback. * * It changes what the unmapped answer has to say, because that answer's remedy * names a directory to record the project under. The CLI's fallback is the shell * it runs in, which IS the user's directory, so it defaults to true. The tool's - * fallback is the server's own process cwd — fixed at spawn, and for a plugin- + * last fallback is the server's own process cwd — fixed at spawn, and for a plugin- * or desktop-launched server nowhere near the user — so recording a project * there leaves every later call, which does pass a cwd, resolving nothing. Said * out loud for the same reason missingProjectError says it. */ cwdWasNamed?: boolean; + /** How the answer names an unnamed `cwd`; the MCP server's own working directory by default. */ + guessedDirectory?: string; } /** @@ -288,6 +292,7 @@ export function reportProject({ remedies, notes, cwdWasNamed = true, + guessedDirectory = SERVER_DIRECTORY, }: ProjectReportRequest): ProjectReport { // The caller's own array, appended to in place. A note is often the CAUSE of the // failure it accompanies, and resolution can THROW after emitting one — an ignored @@ -326,7 +331,7 @@ export function reportProject({ ...(cwdWasNamed ? [] : [ - `That is the MCP server's own working directory, and it is the only directory searched because no cwd argument was passed. If it is not where the user is, call this again with cwd set to their directory BEFORE recording anything — a project recorded here would not be found from theirs.`, + `That is ${guessedDirectory}, and it is the only directory searched because no cwd argument was passed and the client named no directory. If it is not where the user is, call this again with cwd set to their directory BEFORE recording anything — a project recorded here would not be found from theirs.`, ]), // Named only where the caller chose the directory. Otherwise the one next // step is the warning above — call again with the user's own cwd — and a @@ -373,7 +378,7 @@ export function reportProject({ const baseUrlIsUnresolved = sources.baseUrl === 'unresolved'; if (!cwdWasNamed && (sources.projectUrl !== 'environment' || baseUrlIsUnresolved)) { notes.push( - `No cwd argument was passed, so this answer is about ${cwd}, the MCP server's own working directory. Pass cwd on every Form.io tool call to target the user's directory.` + `No cwd argument was passed and the client named no directory, so this answer is about ${cwd}, ${guessedDirectory}. Pass cwd, set to the user's directory, on later Form.io tool calls.` ); } @@ -504,7 +509,7 @@ export function reportProject({ ...(cwdWasNamed ? [] : [ - `That directory is the MCP server's own working directory, and it is the only directory this answer is about because no cwd argument was passed. If it is not where the user is, call this again with cwd set to their directory BEFORE recording anything — a Base URL recorded here would not be found from theirs.`, + `That directory is ${guessedDirectory}, and it is the only directory this answer is about because no cwd argument was passed and the client named no directory. If it is not where the user is, call this again with cwd set to their directory BEFORE recording anything — a Base URL recorded here would not be found from theirs.`, ]), // Gated for the same reason, and on the same condition, as the structured // remedy above: a remedy that records a deployment under a directory the diff --git a/packages/mcp-server/src/project-resolver.ts b/packages/mcp-server/src/project-resolver.ts index e9228a32..f5b8f07a 100644 --- a/packages/mcp-server/src/project-resolver.ts +++ b/packages/mcp-server/src/project-resolver.ts @@ -34,9 +34,15 @@ import { CommittedProjectConfig, findCommittedConfig, } from './committed-config.js'; +import { ToolError } from './tool-errors.js'; +import { SERVER_DIRECTORY } from './workspace-directory.js'; +// Repeated on every project-scoped tool, so it says only that it is optional and what +// it defaults to; when to pass it, and the order a directory is chosen in, are the +// server instructions', which every client receives once. A test holds it to 100 +// characters. const CWD_DESCRIPTION = - "User's current working directory as an absolute path. Selects the Form.io project that directory resolves to, by scope, narrowest first: a committed formio.json found by walking up from it, then the working-directory mapping project_set writes, then FORMIO_PROJECT_URL in the environment, which is the weakest of the three. Pass it on EVERY call whenever you know it: omitting it resolves against the MCP server's own working directory, which is fixed at spawn and may resolve to a different project. No environment variable removes the need for it — the environment is the source a file or a mapping overrides, not the one that overrides them."; + "Optional. Directory whose project to use; defaults to the client's workspace root."; // One schema for every client. Requiredness cannot live here: whether a cwd is // needed depends on the environment the server was launched with, and this @@ -58,6 +64,8 @@ export const cwdSchema = z interface MissingProject { cwd: string | undefined; mapCwd: string; + /** How to name `mapCwd` when nobody named it: the server's own directory, or a launch default. */ + guessedDirectory: string; /** Deployments found with no project beside them, named so nothing is overwritten unseen. */ unpaired: string[]; } @@ -67,7 +75,7 @@ interface MissingProject { // exclusion turned every OTHER error — including ones added later — into a false // "nothing configured", whose remedy is an interview that writes a record the real // problem still shadows. -export class ProjectNotConfiguredError extends Error { +export class ProjectNotConfiguredError extends ToolError { /** * Values the resolver set aside on the way to "nothing is configured". * @@ -80,23 +88,28 @@ export class ProjectNotConfiguredError extends Error { readonly unpaired: string[]; constructor(message: string, unpaired: string[] = []) { - super(message); + super({ code: 'NOT_CONFIGURED', message }); this.name = 'ProjectNotConfiguredError'; this.unpaired = unpaired; } } -function missingProjectError({ cwd, mapCwd, unpaired }: MissingProject): ProjectNotConfiguredError { +function missingProjectError({ + cwd, + mapCwd, + guessedDirectory, + unpaired, +}: MissingProject): ProjectNotConfiguredError { // Which directory was searched is the whole answer when no cwd was passed: the // server's own is not the user's, so "nothing is configured" without it sends // the caller to project_set, which writes a mapping the next cwd-passing call // will not find — and the loop repeats with the cause never named. const where = cwd ? ` for cwd=${cwd}` - : ` for ${mapCwd}, the MCP server's own working directory, which is the only directory searched because no cwd argument was passed`; + : ` for ${mapCwd}, ${guessedDirectory}, which is the only directory searched because no cwd argument was passed and the client named no directory`; const how = cwd ? `project_set with cwd=${cwd} and the project URL` - : "project_set with cwd set to the user's current working directory and the project URL — and pass that same cwd on every Form.io tool call"; + : "project_set with cwd set to the user's current working directory and the project URL — and pass that same cwd on later Form.io tool calls"; // Names the remedy in BOTH vocabularies, because the same string reaches an // agent holding MCP tools and a caller holding a shell, and neither can act on // the other's form. Carries the shape guidance for the same reason: no skill @@ -161,6 +174,15 @@ export function baseUrlWriteCommand({ return projectCommand(`set --project-url ${projectUrl} --base-url --cwd ${cwd}`); } +// The project is configured and only its deployment is missing — a different remedy +// from "nothing configured", so a different code. +export class BaseUrlUnresolvedError extends ToolError { + constructor(message: string) { + super({ code: 'BASE_URL_UNRESOLVED', message }); + this.name = 'BaseUrlUnresolvedError'; + } +} + export function requireBaseUrl(config: ResolvedFormioConfig): string { if (config.baseUrl) { return config.baseUrl; @@ -176,7 +198,7 @@ export function requireBaseUrl(config: ResolvedFormioConfig): string { : source === 'committed' ? `Record it beside the project, in the committed ${COMMITTED_CONFIG_FILE} that holds it: add "baseUrl": "" beside "projectUrl" in ${config.committedFilePath ?? `that file`} — edit it directly; this server reads a committed file and never writes one, and a mapping written under it does not take effect` : `${config.projectUrl} comes from FORMIO_PROJECT_URL in the environment, which project_set cannot write, so record the pair for this directory: call project_set with cwd ${cwd}, projectUrl ${config.projectUrl} and that baseUrl, or run: ${baseUrlWriteCommand({ source, cwd, projectUrl: config.projectUrl })}`; - throw new Error( + throw new BaseUrlUnresolvedError( `The Base URL for ${config.projectUrl} cannot be determined, so JWT authentication cannot proceed. ` + `${BASE_URL_UNRESOLVED_GUIDANCE} ` + `Guessing one would build the portal-login URL and key the cached token against a deployment you do not use. ` + @@ -240,6 +262,15 @@ export interface ProjectResolution { export interface ResolveProjectOptions { cacheDir?: string; onNote?: (message: string) => void; + /** + * Whether `cwd` is a directory something NAMED — the caller's argument, the client's + * workspace root, CLAUDE_PROJECT_DIR — rather than the server process's own, which + * is fixed at spawn. Only the latter earns the note and the remedy telling the agent + * to pass cwd. Defaults to whether a cwd was given at all. + */ + cwdWasNamed?: boolean; + /** How the answer names an unnamed `cwd`; the MCP server's own working directory by default. */ + guessedDirectory?: string; } interface MappedEntryRead { @@ -511,10 +542,15 @@ export function resolveProject( { cacheDir, onNote = (message) => process.stderr.write(`${message}\n`), + cwdWasNamed = Boolean(cwd), + guessedDirectory = SERVER_DIRECTORY, }: ResolveProjectOptions = {} ): ProjectResolution { if (cwd && !path.isAbsolute(cwd)) { - throw new Error(`cwd must be an absolute path (received: ${cwd}).`); + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: `cwd must be an absolute path (received: ${cwd}).`, + }); } const envProjectUrl = baseConfig.projectUrl; @@ -620,8 +656,9 @@ export function resolveProject( } if (!winner) { throw missingProjectError({ - cwd: cwd || undefined, + cwd: cwdWasNamed ? mapCwd : undefined, mapCwd, + guessedDirectory, // Each value carries its own consequence. A blanket "the write below replaces // that entry" was appended to the joined list, which is true of the mapping and // false of the environment — a variable no write can touch, reported as @@ -645,9 +682,9 @@ export function resolveProject( // Said out loud for the same reason project_set warns on the write side: the // server's process cwd is fixed at spawn and, for a plugin- or desktop-launched // server, is not where the user is. - if (!cwd && projectUrlSource !== 'environment') { + if (!cwdWasNamed && projectUrlSource !== 'environment') { onNote( - `No cwd argument was passed, so the project was resolved from ${mapCwd}, the MCP server's own working directory. Pass cwd on every Form.io tool call to target the user's directory.` + `No cwd argument was passed and the client named no directory, so the project was resolved from ${mapCwd}, ${guessedDirectory}. Pass cwd, set to the user's directory, on later Form.io tool calls.` ); } diff --git a/packages/mcp-server/src/revisions/browser-prompts.ts b/packages/mcp-server/src/revisions/browser-prompts.ts deleted file mode 100644 index 946eb9f6..00000000 --- a/packages/mcp-server/src/revisions/browser-prompts.ts +++ /dev/null @@ -1,296 +0,0 @@ -// 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 { openInBrowser } from '../browser-launch.js'; - -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; - let rejectChoice!: (reason: Error) => void; - const choicePromise = new Promise((resolve, reject) => { - resolveChoice = resolve; - rejectChoice = reject; - }); - - app.get('/', (_req, res) => res.send(page())); - app.post('/callback', (req, res) => { - // Express 5 leaves `req.body` undefined when the request was not JSON. - const choice = (req.body as { choice?: unknown } | undefined)?.choice; - const raw = typeof choice === 'string' ? choice : undefined; - res.send('Choice captured. You can close this tab.'); - resolveChoice(normalize(raw)); - }); - - const server = app.listen(0, '127.0.0.1', (err?: Error) => { - // Express 5 passes a bind failure here rather than raising it on the server. - if (err) { - rejectChoice(new Error(`Could not start the consent page server: ${err.message}`)); - return; - } - const addr = server.address(); - if (addr && typeof addr !== 'string') { - const consentUrl = `http://127.0.0.1:${addr.port}/`; - if (options.openBrowser !== false) { - openInBrowser(consentUrl, (err) => { - process.stderr.write( - `Could not open a browser automatically (${err.message}). ` + - `Open ${consentUrl} to make your choice.\n` - ); - }); - } - 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 index 650763b0..54845b1c 100644 --- a/packages/mcp-server/src/revisions/flows.ts +++ b/packages/mcp-server/src/revisions/flows.ts @@ -1,5 +1,7 @@ +import { isDeepStrictEqual } from 'node:util'; import { ResolvedFormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; +import { ToolError } from '../tool-errors.js'; import { prefixVnote } from './helpers.js'; export const DRAFT_FIELDS = [ @@ -12,6 +14,14 @@ export const DRAFT_FIELDS = [ 'display', ] as const; +/** The form has no draft: GET /draft answered with the live form. */ +export function noDraft(form: string): ToolError { + return new ToolError({ + code: 'NO_DRAFT', + message: `No draft exists for form "${form}". Save one with form_update and draft: true.`, + }); +} + export const REVERT_FIELDS = ['components', 'tags', 'properties', 'display'] as const; export interface DraftFlowOptions { @@ -35,30 +45,97 @@ function pickFields( 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.` - ); +// A draft holds only DRAFT_FIELDS. The body is usually what form_get returned, so +// the other fields it carries are dropped here — a changed caller-editable one is +// refused once the stored form is read (refuseNonDraftChanges). A body with none of the draft fields would stage nothing +// but the note, so that is refused before any request. +function draftFields(form: Record): Record { + const picked = pickFields(form, DRAFT_FIELDS); + if (Object.keys(picked).length === 0) { + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: + `The draft body carries none of the fields a draft saves (${DRAFT_FIELDS.join(', ')}), so nothing was saved. ` + + `To change other fields (title, name, path, access, revisions, …), call form_update without draft: true.`, + }); + } + return picked; +} + +/** + * The fields a caller sets on a form that a draft does not save — they change only + * through a live update. Every other non-draft field form_get returns is the + * server's own (_id, _vid, _rid, revisionId, created, modified, owner, project, + * machineName, deleted, _vnote, _vuser, externalOwner, …) and is ignored in a draft body. + */ +export const EDITABLE_NON_DRAFT_FIELDS = [ + 'title', + 'name', + 'path', + 'type', + 'action', + 'access', + 'submissionAccess', + 'fieldMatchAccess', + 'revisions', + 'submissionRevisions', + 'pdfComponents', + 'translationsUrl', +] as const; + +function changedFrom( + form: Record, + stored: Record, + fields: readonly string[] +): string[] { + return fields.filter((field) => field in form && !isDeepStrictEqual(form[field], stored[field])); +} + +interface NonDraftChangeCheck { + formId: string; + form: Record; + /** What GET /draft answered: the draft, or the live form when there is none. */ + base: Record; + cfg: ResolvedFormioConfig; +} + +// A draft saves only DRAFT_FIELDS, so a caller-editable field the body changes would +// be dropped without a word. Unchanged values — what form_get returned — pass. The +// draft keeps the values it was saved with, so a field it disagrees on is compared +// with the live form too, which is what form_get returns after a rename. +async function refuseNonDraftChanges({ formId, form, base, cfg }: NonDraftChangeCheck) { + const againstBase = changedFrom(form, base, EDITABLE_NON_DRAFT_FIELDS); + if (againstBase.length === 0) { + return; + } + const changed = + base._vid === 'draft' + ? changedFrom( + form, + (await formioFetch(`form/${formId}`, {}, cfg)) as Record, + againstBase + ) + : againstBase; + if (changed.length === 0) { + return; + } + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: + `The draft body changes ${changed.join(', ')}, which a draft does not save, so nothing was saved. ` + + `Save ${changed.length === 1 ? 'that field' : 'those fields'} with form_update without draft, which updates the live form, ` + + `or send the stored ${changed.length === 1 ? 'value' : 'values'} in the draft body.`, + }); } export async function saveDraft({ formId, form, _vnote, cfg }: DraftFlowOptions) { - rejectNonDraftFields(form); + const fields = draftFields(form); // if no draft exists, the endpoint returns the live form const base = (await formioFetch(`form/${formId}/draft`, {}, cfg)) as Record; + await refuseNonDraftChanges({ formId, form, base, cfg }); await formioFetch(`form/${formId}/draft`, {}, cfg, { method: 'PUT', - body: { ...base, ...form, _vnote: prefixVnote(_vnote) }, + body: { ...base, ...fields, _vnote: prefixVnote(_vnote) }, }); // fresh GET since PUT returns stale body return await formioFetch(`form/${formId}/draft`, {}, cfg); @@ -69,9 +146,7 @@ export async function publishDraft({ formId, _vnote, cfg }: Omit; if (draft._vid !== 'draft') { - throw new Error( - `No draft exists for form "${formId}". Create one via form_update with draft: true.` - ); + throw noDraft(formId); } const live = (await formioFetch(`form/${formId}`, {}, cfg)) as Record; await formioFetch(`form/${formId}`, {}, cfg, { @@ -81,11 +156,24 @@ export async function publishDraft({ formId, _vnote, cfg }: Omit, version: string): void { + if (revision._vid === 'draft') { + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: `version ${JSON.stringify(version)} resolved to the form's draft, not a published revision, so nothing was changed. To make the draft live, call form_publish; to restore a published revision, pass its numeric _vid from form_revision_list.`, + }); + } +} + export async function revertToRevision({ formId, version, _vnote, cfg }: RevertOptions) { const revision = (await formioFetch(`form/${formId}/v/${version}`, {}, cfg)) as Record< string, unknown >; + refuseDraftRevision(revision, version); const live = (await formioFetch(`form/${formId}`, {}, cfg)) as Record; await formioFetch(`form/${formId}`, {}, cfg, { method: 'PUT', diff --git a/packages/mcp-server/src/revisions/history.ts b/packages/mcp-server/src/revisions/history.ts new file mode 100644 index 00000000..6009b177 --- /dev/null +++ b/packages/mcp-server/src/revisions/history.ts @@ -0,0 +1,83 @@ +import { ResolvedFormioConfig } from '../config.js'; +import { formioFetch } from '../formio-client.js'; +import { ToolError } from '../tool-errors.js'; + +/** + * A save that would keep no revision history goes ahead only when the caller passes + * `acceptNoHistory: true`. Nothing here asks the user: the refusal is written for the + * agent to relay, and the agent retries with the user's answer. No answer outlives + * the call it was given to. + */ +export function historyNotAccepted({ + reason, + remedy, +}: { + reason: string; + remedy: string; +}): ToolError { + return new ToolError({ + code: 'HISTORY_NOT_ACCEPTED', + message: `Nothing was saved: this save would keep no revision history, because ${reason}. Ask the user how to proceed, then ${remedy}`, + }); +} + +/** + * A body that sets `revisions` to an empty value turns history off. On a licensed + * deployment that is the caller choosing to save without history, so it needs the + * same acceptance as any other save without it, whatever the stored form says. + */ +export function gateDisabledHistory({ + form, + acceptNoHistory, +}: { + form: Record; + acceptNoHistory?: boolean; +}): void { + if (!('revisions' in form) || form.revisions || acceptNoHistory) { + return; + } + throw historyNotAccepted({ + reason: `the body sets revisions to ${JSON.stringify(form.revisions)}, which turns revision history off`, + remedy: + 'if they agree to save without history, retry with acceptNoHistory: true; otherwise omit revisions, or set it to "original" (each submission keeps the form version it was made against) or "current" (submissions always use the latest version).', + }); +} + +export interface FormHistoryGateOptions { + cfg: ResolvedFormioConfig; + formId: string; + form: Record; + acceptNoHistory?: boolean; +} + +/** + * On a licensed deployment, an update to a form whose stored `revisions` is off + * keeps no history unless the caller turns it on (`"original"` or `"current"`). + * Echoing the stored `""` back is not a decision, so it does not pass. Reads the + * stored form only when the answer depends on it. + */ +export async function gateFormHistory({ + cfg, + formId, + form, + acceptNoHistory, +}: FormHistoryGateOptions): Promise { + const enablesHistory = form.revisions === 'original' || form.revisions === 'current'; + if (enablesHistory || acceptNoHistory) { + return; + } + // Only the fields read below: the whole definition is not needed to answer this. + const stored = (await formioFetch(`form/${formId}`, { select: 'revisions,name' }, cfg)) as Record< + string, + unknown + >; + if (stored.revisions) { + return; + } + const name = typeof stored.name === 'string' ? stored.name : formId; + throw historyNotAccepted({ + reason: `form "${name}" has revisions turned off`, + remedy: + 'retry with form.revisions set to "original" (each submission keeps the form version it was made against) or "current" (submissions always use the latest version) to turn history on, or with acceptNoHistory: true to save without it.', + }); +} diff --git a/packages/mcp-server/src/revisions/index.ts b/packages/mcp-server/src/revisions/index.ts index a6f5d736..03e2c637 100644 --- a/packages/mcp-server/src/revisions/index.ts +++ b/packages/mcp-server/src/revisions/index.ts @@ -1,4 +1,4 @@ -export { saveDraft, publishDraft, revertToRevision } from './flows.js'; -export { gateRevisionsLicense } from './license.js'; -export { gateRevisionsTracking } from './tracking.js'; +export { saveDraft, publishDraft, revertToRevision, noDraft } from './flows.js'; +export { gateRevisionsLicense, requireRevisionsLicense } from './license.js'; +export { gateFormHistory } from './history.js'; export { prefixVnote } from './helpers.js'; diff --git a/packages/mcp-server/src/revisions/license.ts b/packages/mcp-server/src/revisions/license.ts index 70081ffc..0e587fe4 100644 --- a/packages/mcp-server/src/revisions/license.ts +++ b/packages/mcp-server/src/revisions/license.ts @@ -1,27 +1,63 @@ -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { BASE_URL_UNRESOLVED_GUIDANCE, ResolvedFormioConfig } from '../config.js'; -import { - requestRevisionsLicenseConsent, - RevisionsLicenseConsentChoice, -} from './browser-prompts.js'; +import { gateDisabledHistory, historyNotAccepted } from './history.js'; import { stripRevisions } from './helpers.js'; -import { baseUrlWriteCommand, requireBaseUrl } from '../project-resolver.js'; +import { BaseUrlUnresolvedError, baseUrlWriteCommand } from '../project-resolver.js'; +import { FormioNetworkError, ToolError } from '../tool-errors.js'; +import { send } from '../formio-client.js'; import { COMMITTED_CONFIG_FILE } from '../committed-config.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(); +// `/config.js`, cached per `baseUrl` — license is deployment-wide. A definite answer +// is kept for the life of the process; a probe that produced none is kept for +// UNKNOWN_LICENCE_TTL_MS, so a run of writes against a deployment that is not +// answering does not wait out the probe on every one, and is then asked again. +const UNKNOWN_LICENCE_TTL_MS = 60_000; + +type CachedLicence = { licence: RevisionsLicence; expiresAt: number }; + +const revisionsLicensedByBaseUrl = new Map(); const SAC_PATTERN = /\bsac\s*=\s*(true|false)\b/i; -// Returns undefined — "cannot be determined" — when no base URL resolved, which -// is a third answer distinct from licensed and unlicensed. The flag is a property -// of the deployment and is fetched from it, so with no deployment URL there is -// nothing to ask; reporting `false` would be a claim about a probe that never ran. +// Bounds the probe: an unattended hang here would hold every write behind it. +const LICENCE_PROBE_TIMEOUT_MS = 10_000; + +type DefiniteLicence = { state: 'licensed' } | { state: 'unlicensed' }; + +/** + * What the probe of `{baseUrl}/config.js` established. `unknown` means the probe ran + * and produced no answer — no response, or an error status — and carries that failure + * (NETWORK_ERROR or UPSTREAM_ERROR) naming the URL. + */ +export type RevisionsLicence = DefiniteLicence | { state: 'unknown'; failure: ToolError }; + +async function probeLicence(url: URL): Promise { + try { + const response = await send(url, { signal: AbortSignal.timeout(LICENCE_PROBE_TIMEOUT_MS) }); + if (!response.ok) { + return { + state: 'unknown', + failure: new ToolError({ + code: 'UPSTREAM_ERROR', + message: `the probe of ${url} was answered with status ${response.status}`, + }), + }; + } + const body = await response.text(); + const match = body.match(SAC_PATTERN); + return { state: match?.[1]?.toLowerCase() === 'true' ? 'licensed' : 'unlicensed' }; + } catch (error) { + return { + state: 'unknown', + failure: + error instanceof ToolError + ? error + : new FormioNetworkError({ url: url.toString(), cause: error }), + }; + } +} + // How to record the deployment, named for the record that holds this project — the // same split `requireBaseUrl` makes. A committed file is edited by hand; the mapping // and the environment have commands, and they are different commands. @@ -34,198 +70,147 @@ function baseUrlRemedy(cfg: ResolvedFormioConfig): string { return `Set it with project_set (pass baseUrl alongside the cwd${source === 'environment' ? ', with the projectUrl' : ''}), or run: ${baseUrlWriteCommand({ source, cwd, projectUrl: cfg.projectUrl })}`; } +export interface LicenceCheckOptions { + /** The clock an unknown answer's expiry is measured on, in milliseconds. */ + now?: () => number; +} + +// Returns undefined when no base URL resolved: the flag is a property of the +// deployment and is fetched from it, so with no deployment URL there is nothing to +// ask, and reporting an answer would be a claim about a probe that never ran. export async function checkRevisionsLicensed( - cfg: ResolvedFormioConfig -): Promise { + cfg: ResolvedFormioConfig, + { now = Date.now }: LicenceCheckOptions = {} +): Promise { if (!cfg.baseUrl) return undefined; const baseUrl = cfg.baseUrl; const cached = revisionsLicensedByBaseUrl.get(baseUrl); - if (cached !== undefined) return cached; - - let revisionsLicensed = false; - try { - const url = new URL('config.js', `${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(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 }); + if (cached !== undefined && now() < cached.expiresAt) return cached.licence; + + const licence = await probeLicence(new URL('config.js', `${baseUrl.replace(/\/*$/, '/')}`)); + revisionsLicensedByBaseUrl.set(baseUrl, { + licence, + expiresAt: licence.state === 'unknown' ? now() + UNKNOWN_LICENCE_TTL_MS : Infinity, + }); + return licence; } -async function readRevisionsLicenseConsent( - baseUrl: string, - cacheDir: string = DEFAULT_CACHE_DIR -): Promise { - const data = await readCache(cacheDir); - return data[baseUrl] === true; +// The probe ran and produced no answer. Refused with the probe's own code — the +// deployment was unreachable or answered with an error — and never LICENSE_REQUIRED, +// which would be a claim about the licence that nothing established. +function licenceUnknown(failure: ToolError, actionLabel: string): ToolError { + return new ToolError({ + code: failure.code, + message: `Cannot ${actionLabel} — whether this Form.io deployment's licence includes form revisions could not be determined: ${failure.message}. Nothing was changed; retry once the deployment answers.`, + cause: failure, + }); } -async function saveRevisionsLicenseConsent( - baseUrl: string, - cacheDir: string = DEFAULT_CACHE_DIR -): Promise { - const data = await readCache(cacheDir); - data[baseUrl] = true; - await writeCache(cacheDir, data); +// Why the licence cannot be read: the probe is a GET to the deployment, and no +// deployment resolved. Undetermined is not unlicensed, so this is raised only where +// the answer would change what happens. +function licenceUndetermined( + cfg: ResolvedFormioConfig, + actionLabel: string +): BaseUrlUnresolvedError { + return new BaseUrlUnresolvedError( + `Cannot ${actionLabel} — the Security Module flag is read from {baseUrl}/config.js, and the Base URL for ${cfg.projectUrl} cannot be determined, so that probe never ran. ` + + `${BASE_URL_UNRESOLVED_GUIDANCE} ` + + `This one is needed however you authenticate: the probe is an ANONYMOUS request to the deployment, so an API key does not exempt it. ` + + // The write that reaches the record holding THIS project. The mapping's own + // `--base-url` call is refused where the project lives in a committed file + // or the environment, so naming it unconditionally named a command the + // writer rejects — the failure `baseUrlWriteCommand` exists to prevent, in + // the one message that did not use it. + `${baseUrlRemedy(cfg)}. ` + + `The project itself is configured — only its Base URL is missing, so do not ask for the Project URL again.` + ); } -// ─── In-memory consent layered over the persistent store ─────────────────── -// Lookup order: memory → disk → prompt. -const revisionsLicenseConsentByBaseUrl = new Map(); - -export async function getRevisionsLicenseConsent( - server: McpServer, +/** + * Drafts, publishing and reverting exist only where the deployment is licensed for + * revisions. Refuses with LICENSE_REQUIRED where it is not, with the Base URL remedy + * where the licence cannot be read, and with the probe's own failure where the probe + * produced no answer. + */ +export async function requireRevisionsLicense( cfg: ResolvedFormioConfig, actionLabel: string -): Promise { - // Only reachable once the license resolved to false, which requires a base URL - // to have been probed — an undetermined license returns before this. Narrowed - // through the same guard so the invariant is enforced rather than assumed. - const baseUrl = requireBaseUrl(cfg); - const cached = revisionsLicenseConsentByBaseUrl.get(baseUrl); - if (cached) return cached; - - if (await readRevisionsLicenseConsent(baseUrl)) { - revisionsLicenseConsentByBaseUrl.set(baseUrl, 'continue'); - return 'continue'; +): Promise { + const licence = await checkRevisionsLicensed(cfg); + if (licence === undefined) { + throw licenceUndetermined(cfg, actionLabel); } - - 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 (licence.state === 'unknown') { + throw licenceUnknown(licence.failure, actionLabel); + } + if (licence.state === 'unlicensed') { + throw new ToolError({ + code: 'LICENSE_REQUIRED', + message: `Cannot ${actionLabel} — drafts, publishing and reverting need form revisions, which this Form.io deployment's licence does not include (the Security Module is required). To change the form, call form_update without draft: true.`, }); - 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(baseUrl, actionLabel); } +} - // Only cache positive consent — cancel is transient so users can change their mind. - if (choice === 'continue') { - revisionsLicenseConsentByBaseUrl.set(baseUrl, choice); - await saveRevisionsLicenseConsent(baseUrl); - } - return choice; +export interface RevisionsLicenseGateOptions { + cfg: ResolvedFormioConfig; + actionLabel: string; + form: Record; + acceptNoHistory?: boolean; } -// 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.` - ); - } +export interface RevisionsLicenseGateResult { + /** True only when the deployment is definitely licensed. */ + licensed: boolean; + /** True when the probe ran and produced no answer. */ + licenceUnknown?: boolean; + form: Record; } -// 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); - - // Undetermined is not unlicensed. Demand the Base URL only where the answer - // would change what we do: an explicit draft/publish/revert needs the license - // to be real, and a form carrying a `revisions` setting must not have it - // stripped on the strength of a probe that never ran. A form with no such - // setting loses nothing — stripRevisions is a no-op on it — so an API-key - // write proceeds rather than failing over a capability it never asked about, - // and no consent is requested, because claiming the deployment is unlicensed - // would be a statement we cannot support. - if (licensed === undefined) { - if (requiresRevisions || 'revisions' in form) { - throw new Error( - `Cannot ${actionLabel} — the Security Module flag is read from {baseUrl}/config.js, and the Base URL for ${cfg.projectUrl} cannot be determined, so that probe never ran. ` + - `${BASE_URL_UNRESOLVED_GUIDANCE} ` + - `This one is needed however you authenticate: the probe is an ANONYMOUS request to the deployment, so an API key does not exempt it. ` + - // The write that reaches the record holding THIS project. The mapping's own - // `--base-url` call is refused where the project lives in a committed file - // or the environment, so naming it unconditionally named a command the - // writer rejects — the failure `baseUrlWriteCommand` exists to prevent, in - // the one message that did not use it. - `${baseUrlRemedy(cfg)}. ` + - `The project itself is configured — only its Base URL is missing, so do not ask for the Project URL again.` - ); +/** + * The licence gate for standard creates and updates. On an unlicensed deployment the + * save keeps no history, so it proceeds only with `acceptNoHistory: true`, and then + * with `revisions` stripped. On a licensed one, a body that turns history off + * (`revisions: ""`) needs the same acceptance. Where the probe produced no answer the + * body is sent as the caller wrote it — never stripped, and never refused on the + * licence's account — while a body turning history off still needs acceptance. + */ +export async function gateRevisionsLicense({ + cfg, + actionLabel, + form, + acceptNoHistory, +}: RevisionsLicenseGateOptions): Promise { + const licence = await checkRevisionsLicensed(cfg); + + // Undetermined is not unlicensed. A form carrying a `revisions` setting must not + // have it stripped on the strength of a probe that never ran. A form with no such + // setting loses nothing — stripRevisions is a no-op on it — so an API-key write + // proceeds rather than failing over a capability it never asked about, and is not + // refused as unlicensed, because that would be a claim we cannot support. + if (licence === undefined) { + if ('revisions' in form) { + throw licenceUndetermined(cfg, actionLabel); } return { licensed: false, form }; } - - 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.` - ); + // The probe ran and produced no answer. Stripping or refusing would act on a licence + // nothing established, so the body goes as written; only the caller's own choice to + // turn history off is gated, exactly as it is where the licence is known. + if (licence.state === 'unknown') { + gateDisabledHistory({ form, acceptNoHistory }); + return { licensed: false, licenceUnknown: true, form }; } - // 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); + if (licence.state === 'licensed') { + gateDisabledHistory({ form, acceptNoHistory }); + return { licensed: true, form }; + } + if (!acceptNoHistory) { + throw historyNotAccepted({ + reason: + "this Form.io deployment's licence does not include form revisions (the Security Module is required)", + remedy: 'if they agree to save without history, retry with acceptNoHistory: true.', + }); } - return { licensed, form: licensed ? form : stripRevisions(form) }; + return { licensed: false, form: stripRevisions(form) }; } diff --git a/packages/mcp-server/src/revisions/tracking.ts b/packages/mcp-server/src/revisions/tracking.ts deleted file mode 100644 index 591299d2..00000000 --- a/packages/mcp-server/src/revisions/tracking.ts +++ /dev/null @@ -1,127 +0,0 @@ -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/server.ts b/packages/mcp-server/src/server.ts index 1ec4c41a..f2fc2216 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { DEFAULT_BASE_URL, FormioConfig, PROJECT_URL_GUIDANCE, getConfig } from './config.js'; import { COMMITTED_CONFIG_FILE } from './committed-config.js'; import { registerAllTools } from './tools/index.js'; -import { SERVER_VERSION } from './cli-launch.js'; +import { SERVER_NAME, SERVER_VERSION } from './cli-launch.js'; // Re-exported from cli-launch.ts, which owns it alongside the runnable spelling // of the CLI those messages print — one module that knows which published build @@ -17,8 +17,9 @@ export const SERVER_INSTRUCTIONS = [ 'There is ONE value to ask the user for before the first project-scoped call: the Project URL, the full URL of their Form.io project. Do not ask for a Base URL as well — that value is derived from the Project URL wherever it can be, so asking for it up front asks for something usually never needed.', PROJECT_URL_GUIDANCE, `The Base URL is the deployment hosting the project. It is DERIVED rather than defaulted: ${DEFAULT_BASE_URL} for a project on a form.io host, and for a sub-directory Project URL the value left by dropping its final path segment. For a path-less Project URL on any other host it cannot be derived at all — that shape names no deployment — so it stays unresolved and the first call that needs it fails asking for it alone. Wait for that failure rather than pre-empting it: the Base URL builds the portal-login URL and keys the cached token, so a guessed one sends the login to a deployment the user does not use.`, - "Persist the Project URL with project_set, passing the cwd argument set to the user's current working directory. Every tool resolves its project on each call, so it needs no restart — but it governs only where the mapping is the record that wins: under a committed formio.json the mapping is the fallback, and project_set reports the pair that actually resolves plus an `ok` saying whether the directory can now serve a call.", - 'Before asking for anything, ask the server: call project_get with cwd set to the user\'s current working directory and branch on the `status` it returns. "ok" means both URLs resolved and you may proceed. "not-configured" means nothing is recorded for that directory — ask for the single value the message names and record it with project_set. "base-url-unresolved" means the project IS recorded and only its deployment is missing — ask the user for the Base URL alone, then do what the report names, which is NOT always a call: where a committed formio.json holds the project the remedy is an edit adding a "baseUrl" key to that file, this server never writes one, and a project_set carrying a baseUrl alone is refused there. A project and its deployment are recorded together in ONE record, so which call that is depends on which record holds the project, and the report names it rather than leaving you to compose one; it already carries the Project URL, so do not re-ask the user for it. A call that fails outright rather than returning a status could not answer at all — an unreadable map, a formio.json that will not parse — so relay it rather than interviewing, because project_set would fail for the same unreported reason.', + `Each call resolves its project for one directory, chosen in this order: the cwd argument; the client's workspace roots, when the client declares the roots capability — one root as is, and with several roots the first that holds a project record when they all hold the same one, none if no root holds one, and when they hold different ones the call fails with INVALID_ARGUMENT listing them and you pass cwd; the CLAUDE_PROJECT_DIR environment variable; and last this server's own working directory, which is fixed at spawn and may resolve to a different project. project_get and server_status report which one supplied it as cwdSource. Call project_get without cwd first. When cwdSource is "server" or "claude-project-dir", the directory is a guess rather than one the caller or client named, so pass cwd, set to the user's current working directory as an absolute path, on later calls; otherwise pass cwd only to target a different directory, such as one application in a monorepo.`, + 'Persist the Project URL with project_set, which records it for the directory the call resolves to, by the same order. Every tool resolves its project on each call, so it needs no restart — but it governs only where the mapping is the record that wins: under a committed formio.json the mapping is the fallback, and project_set reports the pair that actually resolves plus an `ok` saying whether the directory can now serve a call.', + 'Before asking for anything, ask the server: call project_get and branch on the `status` it returns. "ok" means both URLs resolved and you may proceed. "not-configured" means nothing is recorded for that directory — ask for the single value the message names and record it with project_set. "base-url-unresolved" means the project IS recorded and only its deployment is missing — ask the user for the Base URL alone, then do what the report names, which is NOT always a call: where a committed formio.json holds the project the remedy is an edit adding a "baseUrl" key to that file, this server never writes one, and a project_set carrying a baseUrl alone is refused there. A project and its deployment are recorded together in ONE record, so which call that is depends on which record holds the project, and the report names it rather than leaving you to compose one; it already carries the Project URL, so do not re-ask the user for it. A call that fails outright rather than returning a status could not answer at all — an unreadable map, a formio.json that will not parse — so relay it rather than interviewing, because project_set would fail for the same unreported reason.', `A project and its deployment are recorded TOGETHER, in one record: precedence picks the record and both values come from it, and a write leaves that record holding both — a Project URL that names no deployment (a path-less URL on a customer domain) has to be recorded with its Base URL in the same call. A Base URL identical to the Project URL is refused: that names an Open Source server, and these tools address a project under a deployment. There are three places a project can come from, and they resolve narrowest-scope-first: a committed ${COMMITTED_CONFIG_FILE} holding {"projectUrl": "...", "baseUrl": "..."} found by walking up from the working directory, then the per-directory mapping project_set writes, then FORMIO_PROJECT_URL and FORMIO_BASE_URL in the environment, which are the weakest.`, `Prefer the committed ${COMMITTED_CONFIG_FILE} when the target belongs to the application being built: it is tracked in version control, so it survives a clone and is visible in review. Write that file yourself, in the application's own folder — a JSON object holding {"projectUrl": "..."}, plus "baseUrl" only when it cannot be derived; this server reads it and never writes it. The per-directory mapping, written with project_set, is the right choice when the target belongs to this machine rather than to the code.`, 'FORMIO_PROJECT_URL no longer pins the server: a committed file or a per-directory mapping overrides it, so project_set can redirect a directory whose environment names a different project. There is no variable that merely offers a project — the environment is already the weakest source, so a project set there suggests without pinning.', @@ -28,7 +29,7 @@ export const SERVER_INSTRUCTIONS = [ export function createServer(config?: FormioConfig): McpServer { const resolvedConfig = config ?? getConfig(); const server = new McpServer( - { name: 'formio-mcp', version: SERVER_VERSION }, + { name: SERVER_NAME, version: SERVER_VERSION }, { instructions: SERVER_INSTRUCTIONS } ); registerAllTools(server, resolvedConfig); diff --git a/packages/mcp-server/src/token-validation.ts b/packages/mcp-server/src/token-validation.ts index ba037a5b..dd707def 100644 --- a/packages/mcp-server/src/token-validation.ts +++ b/packages/mcp-server/src/token-validation.ts @@ -1,5 +1,6 @@ import { FormioConfig } from './config.js'; import { getAuthHeader } from './auth-header.js'; +import { send } from './formio-client.js'; // baseUrl is required here and the type cannot say so: FormioConfig leaves it // optional, and template interpolation would happily fetch "undefined/current" @@ -11,8 +12,10 @@ export async function validateToken(config: FormioConfig): Promise { 'validateToken requires a resolved Base URL; the auth path must call requireBaseUrl before validating a token.' ); } - const url = `${config.baseUrl}/current`; - // Not followed: a redirect would carry the token to wherever it points. - const response = await fetch(url, { headers: getAuthHeader(config), redirect: 'manual' }); + const url = new URL(`${config.baseUrl}/current`); + // Not followed: a redirect would carry the token to wherever it points. A request + // that gets no response is a NETWORK_ERROR naming the cause and URL, as every + // other Form.io request reports it. + const response = await send(url, { headers: getAuthHeader(config), redirect: 'manual' }); return response.ok; } diff --git a/packages/mcp-server/src/tool-errors.ts b/packages/mcp-server/src/tool-errors.ts new file mode 100644 index 00000000..60cc02b9 --- /dev/null +++ b/packages/mcp-server/src/tool-errors.ts @@ -0,0 +1,233 @@ +/** + * The error contract every tool shares: a fixed set of codes a caller can branch on, + * carried beside the prose message a model reads. + * + * Adding a code is a minor change; renaming or removing one is breaking. + */ +export const TOOL_ERROR_CODES = [ + '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', + 'INTERNAL', +] as const; + +export type ToolErrorCode = (typeof TOOL_ERROR_CODES)[number]; + +/** How much of Form.io's response body an error carries. */ +export const FORMIO_BODY_LIMIT = 2000; + +export interface ToolErrorOptions { + code: ToolErrorCode; + message: string; + cause?: unknown; +} + +/** An error whose code is known where it is raised. */ +export class ToolError extends Error { + readonly code: ToolErrorCode; + + constructor({ code, message, cause }: ToolErrorOptions) { + super(message, cause === undefined ? undefined : { cause }); + this.name = 'ToolError'; + this.code = code; + } +} + +/** The code a Form.io error response maps to. */ +export function codeForResponse({ + status, + body, +}: { + status: number; + body?: string; +}): ToolErrorCode { + if (status >= 300 && status < 400) { + return 'REDIRECTED'; + } + if (status === 401) { + return 'AUTH_REQUIRED'; + } + if (status === 403) { + return 'FORBIDDEN'; + } + // Form.io answers an unknown form path alias with 400 "Invalid alias": the form + // was not found, whatever the status says. + if (status === 404 || (status === 400 && /invalid alias/i.test(body ?? ''))) { + return 'NOT_FOUND'; + } + if (status === 400 || status === 422) { + return 'VALIDATION_FAILED'; + } + return 'UPSTREAM_ERROR'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +// One entry's message: a bare string, or an object carrying `message`. +function entryMessage(entry: unknown): string | undefined { + if (typeof entry === 'string') { + return entry.trim() || undefined; + } + return isRecord(entry) ? stringField(entry, 'message') : undefined; +} + +// Field-level messages: resourcejs reports a validation failure as an `errors` map +// keyed by field and a missing document as an `errors` array of strings, and the +// submission validator reports a `details` array. +function fieldMessages(record: Record): string[] { + const { errors, details } = record; + const entries = [ + ...(isRecord(errors) ? Object.values(errors) : []), + ...(Array.isArray(errors) ? errors : []), + ...(Array.isArray(details) ? details : []), + ]; + return entries.flatMap((entry) => { + const message = entryMessage(entry); + return message ? [message] : []; + }); +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +const SHORT_TEXT_LIMIT = 200; + +/** + * Form.io's own explanation of a failed request, for the prose message: the JSON + * `message` (or `name`) plus any field-level messages it does not already contain, + * or a short plain-text body such as "Invalid alias". Undefined when the body + * explains nothing a reader could use, such as an HTML error page. + */ +export function formioErrorText(body: string | undefined): string | undefined { + const text = body?.trim(); + if (!text) { + return undefined; + } + const parsed = parseJson(text); + if (isRecord(parsed)) { + const lead = stringField(parsed, 'message') ?? stringField(parsed, 'name'); + const extra = fieldMessages(parsed).filter((message) => !lead?.includes(message)); + const parts = [...(lead ? [lead] : []), ...new Set(extra)]; + return parts.length ? parts.join(' ') : undefined; + } + if (parsed !== undefined || text.startsWith('<') || text.length > SHORT_TEXT_LIMIT) { + return undefined; + } + return text; +} + +export interface FormioApiErrorOptions { + status: number; + url: string; + /** The response body as text; truncated to FORMIO_BODY_LIMIT here. */ + body?: string; + /** Where a 3xx pointed. */ + location?: string; +} + +/** Form.io answered, and the answer was not a success. */ +export class FormioApiError extends ToolError { + readonly status: number; + readonly url: string; + readonly body?: string; + + constructor({ status, url, body, location }: FormioApiErrorOptions) { + const kept = body ? body.slice(0, FORMIO_BODY_LIMIT) : undefined; + const explanation = formioErrorText(body); + const redirect = location + ? ` | Redirected to ${location}, which is not followed: every request addresses the resolved project directly.` + : ''; + super({ + code: codeForResponse({ status, body }), + message: `Form.io API error: ${status} | URL: ${url}${redirect}${explanation ? ` | ${explanation}` : ''}`, + }); + this.name = 'FormioApiError'; + this.status = status; + this.url = url; + this.body = kept; + } +} + +function errorCode(value: unknown): string | undefined { + if (!isRecord(value) && !(value instanceof Error)) { + return undefined; + } + const code = (value as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +} + +// The system error behind a request that got no response. fetch reports every such +// failure as "fetch failed" and keeps the reason on `cause` — and a host that +// resolves to several addresses nests one error per address in an AggregateError. +function describeCause(error: unknown): string { + const cause = error instanceof Error && error.cause !== undefined ? error.cause : error; + const nested = cause instanceof AggregateError ? cause.errors : []; + const code = errorCode(cause) ?? nested.map(errorCode).find(Boolean); + const detail = + (cause instanceof Error && cause.message) || + nested.map((inner) => (inner instanceof Error ? inner.message : '')).find(Boolean) || + (error instanceof Error ? error.message : String(error)); + return code && !detail.includes(code) ? `${code}: ${detail}` : detail; +} + +/** The request to Form.io received no response. */ +export class FormioNetworkError extends ToolError { + readonly url: string; + + constructor({ url, cause }: { url: string; cause: unknown }) { + super({ + code: 'NETWORK_ERROR', + message: `Form.io request failed with no response (${describeCause(cause)}) | URL: ${url}`, + cause, + }); + this.name = 'FormioNetworkError'; + this.url = url; + } +} + +// A type alias rather than an interface, so it is assignable to the +// `Record` an MCP result's `_meta` values are checked against. +export type ToolErrorDetails = { + code: ToolErrorCode; + status?: number; + body?: string; +}; + +/** The structured half of a tool error: its code, and Form.io's status and body when it answered. */ +export function toolErrorDetails(error: unknown): ToolErrorDetails { + if (error instanceof FormioApiError) { + return { + code: error.code, + status: error.status, + ...(error.body ? { body: error.body } : {}), + }; + } + if (error instanceof ToolError) { + return { code: error.code }; + } + return { code: 'INTERNAL' }; +} diff --git a/packages/mcp-server/src/tools/action-schema.ts b/packages/mcp-server/src/tools/action-schema.ts index 8586ac43..3ad17ac3 100644 --- a/packages/mcp-server/src/tools/action-schema.ts +++ b/packages/mcp-server/src/tools/action-schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ToolError } from '../tool-errors.js'; const conditionOperator = z.enum([ 'isEqual', @@ -32,18 +33,34 @@ const actionCondition = z }) ), }) - .optional() - .describe('Condition for when action runs'); + .optional(); +// Form.io's own fields, documented by the formio-actions skill, so they carry no +// description here: `name` comes from action_type_list, and the keys valid in +// `settings` from action_type_get's settingsForm. export const actionDefinitionSchema = z .object({ - name: z.string().describe('Action type name'), - title: z.string().describe('Action title'), - handler: z.array(z.string()).describe('Handler phases (e.g. ["before"], ["after"])'), - method: z.array(z.string()).describe('Methods (e.g. ["create"], ["update"])'), - settings: z.record(z.string(), z.unknown()).optional().describe('Action settings'), + name: z.string(), + title: z.string(), + handler: z.array(z.string()), + method: z.array(z.string()), + settings: z.record(z.string(), z.unknown()).optional(), condition: actionCondition, - priority: z.number().optional().describe('Action priority'), + priority: z.number().optional(), }) .passthrough() .describe('Action definition'); + +/** The refusal for an action type the form's catalog does not offer. */ +export function unknownActionType({ + name, + available, +}: { + name: string; + available: readonly string[]; +}): ToolError { + return new ToolError({ + code: 'UNKNOWN_ACTION_TYPE', + message: `Action type '${name}' is not available on this server. Available types: ${available.join(', ')}`, + }); +} diff --git a/packages/mcp-server/src/tools/action_create.ts b/packages/mcp-server/src/tools/action_create.ts index 7d64aca6..f05e7d35 100644 --- a/packages/mcp-server/src/tools/action_create.ts +++ b/packages/mcp-server/src/tools/action_create.ts @@ -2,11 +2,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { actionShape } from '../output-schemas.js'; +import { actionDocument } from '../output-schemas.js'; import { creates } from '../tool-annotations.js'; -import { actionDefinitionSchema } from './action-schema.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; -import { resourceSegmentArgument } from './path-arguments.js'; +import { actionDefinitionSchema, unknownActionType } from './action-schema.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; export function registerActionCreateTool(server: McpServer, config: FormioConfig) { server.registerTool( @@ -16,29 +17,22 @@ export function registerActionCreateTool(server: McpServer, config: FormioConfig 'Create a new action on a form. Call action_type_get first to discover the required settings schema for the action type.', inputSchema: { cwd: cwdSchema, - formId: resourceSegmentArgument('formId').describe('The form ID to attach the action to'), + formId: formIdArgument(), action: actionDefinitionSchema, }, - outputSchema: actionShape, + outputSchema: actionDocument, annotations: creates('Create a form action'), }, async ({ cwd, formId, action }) => { try { - const cfg = resolveProjectConfig(cwd, config); + requireFormId(formId); + const cfg = await resolveToolConfig({ server, cwd, config }); const catalog = (await formioFetch(`form/${formId}/actions`, {}, cfg)) as Array<{ name: string; }>; const availableNames = catalog.map((t) => t.name); if (!availableNames.includes(action.name)) { - return { - content: [ - { - type: 'text' as const, - text: `Action type '${action.name}' is not available on this server. Available types: ${availableNames.join(', ')}`, - }, - ], - isError: true, - }; + throw unknownActionType({ name: action.name, available: availableNames }); } const created = (await formioFetch(`form/${formId}/action`, {}, cfg, { diff --git a/packages/mcp-server/src/tools/action_delete.ts b/packages/mcp-server/src/tools/action_delete.ts index 37b54484..a02a112e 100644 --- a/packages/mcp-server/src/tools/action_delete.ts +++ b/packages/mcp-server/src/tools/action_delete.ts @@ -2,9 +2,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpError, toMcpStructuredResult } from '../mcp-responses.js'; -import { acknowledgementShape } from '../output-schemas.js'; +import { acknowledgementOutput } from '../output-schemas.js'; import { removes } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionDeleteTool(server: McpServer, config: FormioConfig) { @@ -15,15 +17,16 @@ export function registerActionDeleteTool(server: McpServer, config: FormioConfig 'Delete an action from a form. The action stops running on submissions immediately and is not recoverable — call action_get first if the settings may be needed again.', inputSchema: { cwd: cwdSchema, - formId: resourceSegmentArgument('formId').describe('The form ID the action belongs to'), + formId: formIdArgument(), actionId: resourceSegmentArgument('actionId').describe('The action ID to delete'), }, - outputSchema: acknowledgementShape, + outputSchema: acknowledgementOutput, annotations: removes('Delete a form action'), }, async ({ cwd, formId, actionId }) => { try { - const cfg = resolveProjectConfig(cwd, config); + requireFormId(formId); + const cfg = await resolveToolConfig({ server, cwd, config }); // The API answers this DELETE with the plain text body `OK`, so the // response is read as text. Left to default to `res.json()` it threw // "Unexpected token 'O'" and the delete reported failure after succeeding. diff --git a/packages/mcp-server/src/tools/action_get.ts b/packages/mcp-server/src/tools/action_get.ts index 388ae811..ab3b1b19 100644 --- a/packages/mcp-server/src/tools/action_get.ts +++ b/packages/mcp-server/src/tools/action_get.ts @@ -2,9 +2,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { actionShape } from '../output-schemas.js'; +import { actionDocument } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionGetTool(server: McpServer, config: FormioConfig) { @@ -15,15 +17,16 @@ export function registerActionGetTool(server: McpServer, config: FormioConfig) { 'Get a single action by ID from a form, including its handler, method, condition, and type-specific settings. Call action_list first to find the action ID.', inputSchema: { cwd: cwdSchema, - formId: resourceSegmentArgument('formId').describe('The form ID the action belongs to'), + formId: formIdArgument(), actionId: resourceSegmentArgument('actionId').describe('The action ID to retrieve'), }, - outputSchema: actionShape, + outputSchema: actionDocument, annotations: reads('Get a form action'), }, async ({ cwd, formId, actionId }) => { try { - const cfg = resolveProjectConfig(cwd, config); + requireFormId(formId); + const cfg = await resolveToolConfig({ server, cwd, config }); const action = (await formioFetch(`form/${formId}/action/${actionId}`, {}, cfg)) as Record< string, unknown diff --git a/packages/mcp-server/src/tools/action_list.ts b/packages/mcp-server/src/tools/action_list.ts index 47ee5a9c..e146efd3 100644 --- a/packages/mcp-server/src/tools/action_list.ts +++ b/packages/mcp-server/src/tools/action_list.ts @@ -1,33 +1,33 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { FormioConfig } from '../config.js'; -import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { actionsListShape } from '../output-schemas.js'; +import { actionsListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; -import { resourceSegmentArgument } from './path-arguments.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; +import { fetchListPage, listArguments, listResult } from './list-contract.js'; export function registerActionListTool(server: McpServer, config: FormioConfig) { server.registerTool( 'action_list', { description: - 'List the actions configured on a form — the server-side handlers that run when a submission is saved, such as save-to-resource, email, login, and role assignment.', + 'List the actions configured on a form, one page at a time — the server-side handlers that run when a submission is saved, such as save-to-resource, email, login, and role assignment.', inputSchema: { cwd: cwdSchema, - formId: resourceSegmentArgument('formId').describe('The form ID to list actions for'), + formId: formIdArgument(), + ...listArguments(), }, - outputSchema: actionsListShape, + outputSchema: actionsListOutput, annotations: reads('List form actions'), }, - async ({ cwd, formId }) => { + async ({ cwd, formId, ...query }) => { try { - const cfg = resolveProjectConfig(cwd, config); - const actions = (await formioFetch(`form/${formId}/action`, {}, cfg)) as Record< - string, - unknown - >[]; - return toMcpStructuredResult({ actions, count: actions.length }); + requireFormId(formId); + const cfg = await resolveToolConfig({ server, cwd, config }); + const page = await fetchListPage({ path: `form/${formId}/action`, query, config: cfg }); + return toMcpStructuredResult(listResult('actions', page)); } catch (error) { return toMcpError(error); } diff --git a/packages/mcp-server/src/tools/action_type_get.ts b/packages/mcp-server/src/tools/action_type_get.ts index 20e0c11f..3a3559c6 100644 --- a/packages/mcp-server/src/tools/action_type_get.ts +++ b/packages/mcp-server/src/tools/action_type_get.ts @@ -2,9 +2,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { actionTypeInfoShape } from '../output-schemas.js'; +import { actionTypeInfoDocument } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { unknownActionType } from './action-schema.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionTypeGetTool(server: McpServer, config: FormioConfig) { @@ -15,19 +18,18 @@ export function registerActionTypeGetTool(server: McpServer, config: FormioConfi 'Get action type info and settings form schema. Call this before action_create to discover the required settings for the action type.', inputSchema: { cwd: cwdSchema, - formId: resourceSegmentArgument('formId').describe( - 'The form ID to get the action type for' - ), + formId: formIdArgument(), actionName: resourceSegmentArgument('actionName').describe( 'The action type name (e.g. "email", "save", "login")' ), }, - outputSchema: actionTypeInfoShape, + outputSchema: actionTypeInfoDocument, annotations: reads('Get an action type'), }, async ({ cwd, formId, actionName }) => { try { - const cfg = resolveProjectConfig(cwd, config); + requireFormId(formId); + const cfg = await resolveToolConfig({ server, cwd, config }); try { const typeInfo = (await formioFetch( `form/${formId}/actions/${actionName}`, @@ -39,11 +41,12 @@ export function registerActionTypeGetTool(server: McpServer, config: FormioConfi const catalog = (await formioFetch(`form/${formId}/actions`, {}, cfg).catch(() => { throw error; })) as Array<{ name: string }>; - const availableTypes = catalog.map((t) => t.name).join(', '); - throw new Error( - `Action type '${actionName}' is not available on this server. Available types: ${availableTypes}`, - { cause: error } - ); + const available = catalog.map((t) => t.name); + // A type the catalog offers failed for another reason: report that failure. + if (available.includes(actionName)) { + throw error; + } + throw unknownActionType({ name: actionName, available }); } } catch (error) { return toMcpError(error); diff --git a/packages/mcp-server/src/tools/action_type_list.ts b/packages/mcp-server/src/tools/action_type_list.ts new file mode 100644 index 00000000..c240a089 --- /dev/null +++ b/packages/mcp-server/src/tools/action_type_list.ts @@ -0,0 +1,37 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { FormioConfig } from '../config.js'; +import { formioFetch } from '../formio-client.js'; +import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; +import { actionTypesListOutput } from '../output-schemas.js'; +import { reads } from '../tool-annotations.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; + +export function registerActionTypeListTool(server: McpServer, config: FormioConfig) { + server.registerTool( + 'action_type_list', + { + description: + 'List the action types available for a form. Returns the whole catalog the server supports in one result.', + inputSchema: { + cwd: cwdSchema, + formId: formIdArgument(), + }, + outputSchema: actionTypesListOutput, + annotations: reads('List action types'), + }, + async ({ cwd, formId }) => { + try { + requireFormId(formId); + const cfg = await resolveToolConfig({ server, cwd, config }); + // Form.io does not page this route: the body is the whole catalog. + const catalog = await formioFetch(`form/${formId}/actions`, {}, cfg); + const items = Array.isArray(catalog) ? (catalog as Record[]) : []; + return toMcpStructuredResult({ actionTypes: items, total: items.length, hasMore: false }); + } catch (error) { + return toMcpError(error); + } + } + ); +} diff --git a/packages/mcp-server/src/tools/action_types_list.ts b/packages/mcp-server/src/tools/action_types_list.ts deleted file mode 100644 index 08258d70..00000000 --- a/packages/mcp-server/src/tools/action_types_list.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { FormioConfig } from '../config.js'; -import { formioFetch } from '../formio-client.js'; -import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { actionTypesListShape } from '../output-schemas.js'; -import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; -import { resourceSegmentArgument } from './path-arguments.js'; - -export function registerActionTypesListTool(server: McpServer, config: FormioConfig) { - server.registerTool( - 'action_types_list', - { - description: - 'List available action types for a form. Returns the catalog of action types the server supports.', - inputSchema: { - cwd: cwdSchema, - formId: resourceSegmentArgument('formId').describe( - 'The form ID to list available action types for' - ), - }, - outputSchema: actionTypesListShape, - annotations: reads('List action types'), - }, - async ({ cwd, formId }) => { - try { - const cfg = resolveProjectConfig(cwd, config); - const actionTypes = (await formioFetch(`form/${formId}/actions`, {}, cfg)) as Record< - string, - unknown - >[]; - return toMcpStructuredResult({ actionTypes, count: actionTypes.length }); - } catch (error) { - return toMcpError(error); - } - } - ); -} diff --git a/packages/mcp-server/src/tools/action_update.ts b/packages/mcp-server/src/tools/action_update.ts index 62fc79a3..c3352422 100644 --- a/packages/mcp-server/src/tools/action_update.ts +++ b/packages/mcp-server/src/tools/action_update.ts @@ -2,10 +2,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { actionShape } from '../output-schemas.js'; +import { actionDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; import { actionDefinitionSchema } from './action-schema.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { resourceSegmentArgument } from './path-arguments.js'; export function registerActionUpdateTool(server: McpServer, config: FormioConfig) { @@ -13,19 +15,20 @@ export function registerActionUpdateTool(server: McpServer, config: FormioConfig 'action_update', { description: - 'Update an existing action on a form. This is a full replacement of the action document — include every field you want to keep, and call action_get first if you do not already have it.', + 'Update an existing action on a form; call action_get first if you do not already have it. Fields sent overwrite the stored ones (an array or object is stored as given, not merged); top-level fields left out keep their stored value.', inputSchema: { cwd: cwdSchema, - formId: resourceSegmentArgument('formId').describe('The form ID the action belongs to'), + formId: formIdArgument(), actionId: resourceSegmentArgument('actionId').describe('The action ID to update'), action: actionDefinitionSchema, }, - outputSchema: actionShape, + outputSchema: actionDocument, annotations: overwrites('Update a form action'), }, async ({ cwd, formId, actionId, action }) => { try { - const cfg = resolveProjectConfig(cwd, config); + requireFormId(formId); + const cfg = await resolveToolConfig({ server, cwd, config }); const updated = (await formioFetch(`form/${formId}/action/${actionId}`, {}, cfg, { method: 'PUT', body: action, diff --git a/packages/mcp-server/src/tools/form-id.ts b/packages/mcp-server/src/tools/form-id.ts new file mode 100644 index 00000000..d7b7df6d --- /dev/null +++ b/packages/mcp-server/src/tools/form-id.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { requireObjectId } from './object-id.js'; + +/** + * The one rule for `formId`: it is always a form's 24-character ObjectId. Reads that + * Form.io also serves by form path take `formIdOrPath` instead; every write and every + * action tool takes `formId`, because the action-type catalog route does not resolve + * a path and one argument name should mean one thing. + * + * The check runs in the handler rather than in the schema (see requireObjectId). + */ +export function formIdArgument() { + return z.string().describe("The form's _id, a 24-character ObjectId (form_get returns it)"); +} + +/** Refuses a `formId` that is not an ObjectId, before any request is made. */ +export function requireFormId(formId: string): void { + requireObjectId({ + argument: 'formId', + value: formId, + remedy: + "formId takes a form's 24-character ObjectId _id. Read the form's _id with form_get; a form path is accepted only by the formIdOrPath argument of form_get, form_revision_list and form_revision_get.", + }); +} diff --git a/packages/mcp-server/src/tools/form_create.ts b/packages/mcp-server/src/tools/form_create.ts index f32eee49..5c2e8670 100644 --- a/packages/mcp-server/src/tools/form_create.ts +++ b/packages/mcp-server/src/tools/form_create.ts @@ -3,9 +3,10 @@ import { z } from 'zod'; import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { formShape } from '../output-schemas.js'; +import { formDocument } from '../output-schemas.js'; import { creates } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; import { gateRevisionsLicense, prefixVnote } from '../revisions/index.js'; export function registerFormCreateTool(server: McpServer, config: FormioConfig) { @@ -13,48 +14,53 @@ export function registerFormCreateTool(server: McpServer, config: FormioConfig) 'form_create', { description: - '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-schema 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.', + 'Create a form in the project `cwd` resolves to. Build the form JSON with the formio-schema skill first. On a deployment licensed for revisions, `revisions` defaults to "original". Not for a draft of an existing form ("save a draft", "draft "): use form_update with `draft: true`.', inputSchema: { cwd: cwdSchema, form: z .looseObject({ - title: z.string().describe('Human-readable form title'), - name: z.string().describe('Machine name for API references'), - path: z.string().describe('URL path segment for the form'), - components: z - .array(z.record(z.string(), z.unknown())) - .describe('Array of form components'), - type: z.enum(['form', 'resource']).optional().describe('Form type (default: "form")'), - display: z - .enum(['form', 'wizard', 'pdf']) - .optional() - .describe('Display mode (default: "form")'), - tags: z.array(z.string()).optional().describe('Tags for categorization'), + title: z.string(), + name: z.string(), + path: z.string(), + components: z.array(z.record(z.string(), z.unknown())), + type: z.enum(['form', 'resource']).optional(), + display: z.enum(['form', 'wizard', 'pdf']).optional(), + tags: z.array(z.string()).optional(), revisions: z .enum(['current', 'original', '']) .optional() - .describe('Revision mode (default: "original"). Pass "" to disable'), + .describe( + 'Revision mode (default: "original"). "" turns history off and needs acceptNoHistory: true' + ), }) .catchall(z.unknown()) .describe('Form.io form JSON definition'), note: z.string().optional().describe('Note describing the initial revision'), + acceptNoHistory: z + .boolean() + .optional() + .describe( + 'Set true only after the user agrees to save without revision history. Without it, such a save (no revisions licence, or the form has revisions off) is refused with HISTORY_NOT_ACCEPTED and nothing is written.' + ), }, - outputSchema: formShape, + outputSchema: formDocument, annotations: creates('Create a form'), }, - async ({ cwd, form: rawForm, note }) => { + async ({ cwd, form: rawForm, note, acceptNoHistory }) => { try { - const cfg = resolveProjectConfig(cwd, config); - const { licensed, form } = await gateRevisionsLicense(server, cfg, { + const cfg = await resolveToolConfig({ server, cwd, config }); + const { licensed, form } = await gateRevisionsLicense({ + cfg, actionLabel: 'create this form', - requiresRevisions: false, form: rawForm, + acceptNoHistory, }); const created = (await formioFetch('form', {}, cfg, { method: 'POST', body: { - // gate already stripped `revisions` on unlicensed deployments; on - // licensed ones default to 'original' unless the caller overrode. + // The gate already stripped `revisions` on unlicensed deployments; on + // licensed ones default to 'original' unless the caller overrode. Where the + // licence is unknown the body goes as written: no default is claimed. ...(licensed ? { revisions: 'original', ...form } : form), ...(note && { _vnote: prefixVnote(note) }), }, diff --git a/packages/mcp-server/src/tools/form_get.ts b/packages/mcp-server/src/tools/form_get.ts index cd6921a2..022e9bb5 100644 --- a/packages/mcp-server/src/tools/form_get.ts +++ b/packages/mcp-server/src/tools/form_get.ts @@ -3,9 +3,11 @@ import { z } from 'zod'; import { FormioConfig } from '../config.js'; import { formioFetch, isMongoId } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { formShape } from '../output-schemas.js'; +import { formDocument } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { noDraft } from '../revisions/index.js'; import { projectPathArgument } from './path-arguments.js'; export function registerFormGetTool(server: McpServer, config: FormioConfig) { @@ -13,7 +15,7 @@ export function registerFormGetTool(server: McpServer, config: FormioConfig) { 'form_get', { description: - "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.", + 'Fetch a form definition, by _id or path, from the project `cwd` resolves to. `draft: true` fetches its in-flight draft instead of the published form.', inputSchema: { cwd: cwdSchema, formIdOrPath: projectPathArgument('formIdOrPath').describe( @@ -28,12 +30,12 @@ export function registerFormGetTool(server: McpServer, config: FormioConfig) { .optional() .describe("When true, fetch the form's current draft (GET /
/draft)"), }, - outputSchema: formShape, + outputSchema: formDocument, annotations: reads('Get a form'), }, async ({ cwd, formIdOrPath, select, draft }) => { try { - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); const params: Record = { select }; const base = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; const path = draft ? `${base}/draft` : base; @@ -41,9 +43,7 @@ export function registerFormGetTool(server: McpServer, config: FormioConfig) { // 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.` - ); + throw noDraft(formIdOrPath); } return toMcpStructuredResult(form); } catch (error) { diff --git a/packages/mcp-server/src/tools/form_list.ts b/packages/mcp-server/src/tools/form_list.ts index 38840d98..556f9e50 100644 --- a/packages/mcp-server/src/tools/form_list.ts +++ b/packages/mcp-server/src/tools/form_list.ts @@ -1,49 +1,61 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { FormioConfig } from '../config.js'; -import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { formsListShape } from '../output-schemas.js'; +import { formsListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { fetchListPage, listArguments, listResult } from './list-contract.js'; +import { ToolError } from '../tool-errors.js'; -const DEFAULT_SELECT = '_id,title,name,path,type,tags'; -const DEFAULT_LIMIT = 20; +// Summary fields only: a page of full form definitions floods the context. +const DEFAULTS = { select: '_id,title,name,path,type,tags' }; + +// Form.io splits `tags__all` on commas, so a tag holding one would be read as several +// and match forms carrying all of those instead of the one the caller named. +function requireCommaFreeTags(tags: readonly string[] = []): void { + const split = tags.filter((tag) => tag.includes(',')); + if (split.length > 0) { + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: `tags cannot contain a comma: ${split.map((tag) => JSON.stringify(tag)).join(', ')}. Form.io splits the tag filter on commas, so such a tag cannot be matched as one; pass separate tags as separate array entries.`, + }); + } +} export function registerFormListTool(server: McpServer, config: FormioConfig) { server.registerTool( 'form_list', { description: - "List forms from the Form.io project mapped to the user's current working directory with optional filtering and pagination.", + 'List forms from the project `cwd` resolves to, one page at a time, optionally filtered by type and tags.', inputSchema: { cwd: cwdSchema, type: z.enum(['form', 'resource']).optional().describe('Filter by form type'), - limit: z.number().optional().describe('Maximum number of forms to return (default: 20)'), - skip: z.number().optional().describe('Number of forms to skip for pagination'), - sort: z.string().optional().describe('Sort field and direction (e.g. "-created")'), - select: z - .string() + tags: z + .array(z.string()) .optional() - .describe('Comma-separated fields to return (default: _id,title,name,path,type,tags)'), - tags: z.array(z.string()).optional().describe('Filter by tags'), + .describe('Return only forms carrying every one of these tags'), + ...listArguments(DEFAULTS), }, - outputSchema: formsListShape, + outputSchema: formsListOutput, annotations: reads('List forms'), }, - async ({ cwd, type, limit, skip, sort, select, tags }) => { + async ({ cwd, type, tags, ...query }) => { try { - const cfg = resolveProjectConfig(cwd, config); - const params: Record = { - select: select ?? DEFAULT_SELECT, - limit: String(limit ?? DEFAULT_LIMIT), - skip: skip !== undefined ? String(skip) : undefined, - sort, - type, - tags: tags?.join(','), - }; - const forms = (await formioFetch('form', params, cfg)) as Record[]; - return toMcpStructuredResult({ forms, count: forms.length }); + requireCommaFreeTags(tags); + const cfg = await resolveToolConfig({ server, cwd, config }); + // `tags=a,b` matches the literal string "a,b"; `tags__all` is all-of. + const filters = { type, tags__all: tags?.length ? tags.join(',') : undefined }; + const page = await fetchListPage({ + path: 'form', + query, + config: cfg, + defaults: DEFAULTS, + filters, + }); + return toMcpStructuredResult(listResult('forms', page)); } catch (error) { return toMcpError(error); } diff --git a/packages/mcp-server/src/tools/form_publish.ts b/packages/mcp-server/src/tools/form_publish.ts new file mode 100644 index 00000000..db2a233a --- /dev/null +++ b/packages/mcp-server/src/tools/form_publish.ts @@ -0,0 +1,43 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; +import { formDocument } from '../output-schemas.js'; +import { overwrites } from '../tool-annotations.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; +import { publishDraft, requireRevisionsLicense } from '../revisions/index.js'; + +export function registerFormPublishTool(server: McpServer, config: FormioConfig) { + server.registerTool( + 'form_publish', + { + description: + "Publish a form's draft as its live version in the project `cwd` resolves to. Save the draft first with form_update and `draft: true`; a form with no draft fails with NO_DRAFT. Needs the revisions licence (Security Module).", + inputSchema: { + cwd: cwdSchema, + formId: formIdArgument(), + note: z + .string() + .describe( + 'Revision note describing what the draft changes against the live form (e.g. "Add phone field")' + ), + }, + outputSchema: formDocument, + annotations: overwrites('Publish a form draft'), + }, + async ({ cwd, formId, note }) => { + try { + requireFormId(formId); + const cfg = await resolveToolConfig({ server, cwd, config }); + await requireRevisionsLicense(cfg, "publish this form's draft"); + return toMcpStructuredResult( + (await publishDraft({ formId, _vnote: note, cfg })) as Record + ); + } catch (error) { + return toMcpError(error); + } + } + ); +} diff --git a/packages/mcp-server/src/tools/form_revert.ts b/packages/mcp-server/src/tools/form_revert.ts new file mode 100644 index 00000000..bb366b05 --- /dev/null +++ b/packages/mcp-server/src/tools/form_revert.ts @@ -0,0 +1,63 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { FormioConfig } from '../config.js'; +import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; +import { formDocument } from '../output-schemas.js'; +import { overwrites } from '../tool-annotations.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; +import { requireRevisionsLicense, revertToRevision } from '../revisions/index.js'; +import { resourceSegmentArgument } from './path-arguments.js'; +import { ToolError } from '../tool-errors.js'; + +// Form.io stores a form's draft as a revision whose _vid is "draft", so /v/draft +// answers with it. Restoring it here would publish the draft by another name. +function refuseDraftVersion(version: string): void { + if (version.trim().toLowerCase() === 'draft') { + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: `version ${JSON.stringify(version)} is the form's draft, not a published revision. To make the draft live, call form_publish; to restore a published revision, pass its _vid from form_revision_list.`, + }); + } +} + +export function registerFormRevertTool(server: McpServer, config: FormioConfig) { + server.registerTool( + 'form_revert', + { + description: + "Revert a form in the project `cwd` resolves to back to a prior revision: its components, tags, properties and display replace the live form's. Find it with form_revision_list. Needs the revisions licence (Security Module).", + inputSchema: { + cwd: cwdSchema, + formId: formIdArgument(), + version: resourceSegmentArgument('version').describe( + 'The revision to restore: its _vid (e.g. "3") or its revision document _id' + ), + note: z + .string() + .describe( + 'Revision note; use "Reverted to version {version}" unless the user gives another' + ), + }, + outputSchema: formDocument, + annotations: overwrites('Revert a form'), + }, + async ({ cwd, formId, version, note }) => { + try { + requireFormId(formId); + refuseDraftVersion(version); + const cfg = await resolveToolConfig({ server, cwd, config }); + await requireRevisionsLicense(cfg, 'revert this form'); + return toMcpStructuredResult( + (await revertToRevision({ formId, version, _vnote: note, cfg })) as Record< + string, + unknown + > + ); + } 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 index 56d734f8..e32f60d8 100644 --- a/packages/mcp-server/src/tools/form_revision_get.ts +++ b/packages/mcp-server/src/tools/form_revision_get.ts @@ -2,9 +2,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { FormioConfig } from '../config.js'; import { formioFetch, isMongoId } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { formShape } from '../output-schemas.js'; +import { formDocument } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; import { projectPathArgument, resourceSegmentArgument } from './path-arguments.js'; export function registerFormRevisionGetTool(server: McpServer, config: FormioConfig) { @@ -12,7 +13,7 @@ export function registerFormRevisionGetTool(server: McpServer, config: FormioCon 'form_revision_get', { description: - '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`.', + 'Fetch one immutable form revision from the project `cwd` resolves to. Restore it with form_revert, passing its `_vid`.', inputSchema: { cwd: cwdSchema, formIdOrPath: projectPathArgument('formIdOrPath').describe( @@ -24,12 +25,12 @@ export function registerFormRevisionGetTool(server: McpServer, config: FormioCon }, // A revision body is a form definition, plus the revision fields the schema // passes through. - outputSchema: formShape, + outputSchema: formDocument, annotations: reads('Get a form revision'), }, async ({ cwd, formIdOrPath, version }) => { try { - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); const base = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; const revision = (await formioFetch(`${base}/v/${version}`, {}, cfg)) as Record< string, diff --git a/packages/mcp-server/src/tools/form_revision_list.ts b/packages/mcp-server/src/tools/form_revision_list.ts new file mode 100644 index 00000000..273ba5a9 --- /dev/null +++ b/packages/mcp-server/src/tools/form_revision_list.ts @@ -0,0 +1,53 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { FormioConfig } from '../config.js'; +import { isMongoId } from '../formio-client.js'; +import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; +import { revisionsListOutput } from '../output-schemas.js'; +import { reads } from '../tool-annotations.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { fetchListPage, listArguments, listResult } from './list-contract.js'; +import { projectPathArgument } from './path-arguments.js'; + +// Form.io's revision index has no default sort, and a full revision carries the +// whole form definition; newest first and compact metadata is what a caller wants. +const DEFAULTS = { sort: '-_vid', select: '_id,_vid,_vnote,_vuser,created,modified' }; + +// Form.io stores a form's draft as a revision with _vid "draft". Excluded in the +// query rather than from the page, so Form.io's total counts the same rows. +const PUBLISHED_ONLY = { _vid__ne: 'draft' }; + +export function registerFormRevisionListTool(server: McpServer, config: FormioConfig) { + server.registerTool( + 'form_revision_list', + { + description: + "List a form's published revision summaries in the project `cwd` resolves to, newest first, one page at a time. Read one with form_revision_get, or restore one with form_revert, passing its `_vid`.", + inputSchema: { + cwd: cwdSchema, + formIdOrPath: projectPathArgument('formIdOrPath').describe( + 'Form ID (_id) or path alias (e.g. "user/login")' + ), + ...listArguments(DEFAULTS), + }, + outputSchema: revisionsListOutput, + annotations: reads('List form revisions'), + }, + async ({ cwd, formIdOrPath, ...query }) => { + try { + const cfg = await resolveToolConfig({ server, cwd, config }); + const base = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; + const page = await fetchListPage({ + path: `${base}/v`, + query, + config: cfg, + defaults: DEFAULTS, + filters: PUBLISHED_ONLY, + }); + return toMcpStructuredResult(listResult('revisions', page)); + } 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 deleted file mode 100644 index 60bcf90f..00000000 --- a/packages/mcp-server/src/tools/form_revisions_list.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { FormioConfig } from '../config.js'; -import { formioFetch, isMongoId } from '../formio-client.js'; -import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { revisionsListShape } from '../output-schemas.js'; -import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; -import { projectPathArgument } from './path-arguments.js'; - -export function registerFormRevisionsListTool(server: McpServer, config: FormioConfig) { - server.registerTool( - 'form_revisions_list', - { - description: - '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`.', - inputSchema: { - cwd: cwdSchema, - formIdOrPath: projectPathArgument('formIdOrPath').describe( - 'Form ID (_id) or path alias (e.g. "user/login")' - ), - }, - outputSchema: revisionsListShape, - annotations: reads('List form revisions'), - }, - async ({ cwd, formIdOrPath }) => { - try { - const cfg = resolveProjectConfig(cwd, config); - const base = isMongoId(formIdOrPath) ? `form/${formIdOrPath}` : formIdOrPath; - const revisions = (await formioFetch(`${base}/v`, {}, cfg)) as Record[]; - return toMcpStructuredResult({ revisions, count: revisions.length }); - } 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 1a072f17..e0f9c5c9 100644 --- a/packages/mcp-server/src/tools/form_update.ts +++ b/packages/mcp-server/src/tools/form_update.ts @@ -1,133 +1,105 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { FormioConfig } from '../config.js'; -import { formioFetch, MONGO_ID_PATTERN } from '../formio-client.js'; +import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { formShape } from '../output-schemas.js'; +import { formDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { formIdArgument, requireFormId } from './form-id.js'; import { + gateFormHistory, gateRevisionsLicense, - gateRevisionsTracking, prefixVnote, - publishDraft, - revertToRevision, + requireRevisionsLicense, saveDraft, } from '../revisions/index.js'; -import { resourceSegmentArgument } from './path-arguments.js'; export function registerFormUpdateTool(server: McpServer, config: FormioConfig) { server.registerTool( 'form_update', { description: [ - "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-schema skill to understand the schema so that you can 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.', + 'Update a form in the project `cwd` resolves to: read it with form_get, apply the change using the formio-schema skill, and send the form. Fields sent overwrite the stored ones (an array or object is stored as given, not merged); top-level fields left out keep their stored value.', + 'Pass `draft: true` to save a draft instead of the live form; publish it with form_publish, and restore a prior revision with form_revert.', ].join(' '), inputSchema: { cwd: cwdSchema, - formId: z - .string() - .regex(MONGO_ID_PATTERN, 'Must be a 24-character MongoDB ObjectId') - .describe('The _id of the form to update'), + formId: formIdArgument(), form: z .looseObject({ - title: z.string().optional().describe('Human-readable form title'), - name: z.string().optional().describe('Machine name for API references'), - path: z.string().optional().describe('URL path segment for the form'), - components: z - .array(z.record(z.string(), z.unknown())) - .describe('Array of form components'), - 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'), + title: z.string().optional(), + name: z.string().optional(), + path: z.string().optional(), + components: z.array(z.record(z.string(), z.unknown())), + type: z.enum(['form', 'resource']).optional(), + display: z.enum(['form', 'wizard', 'pdf']).optional(), + tags: z.array(z.string()).optional(), revisions: z .enum(['current', 'original', '']) .optional() .describe( - 'Revision mode. Pass "" to disable; omit to leave the stored value unchanged.' + 'Revision mode. Omit to leave the stored value unchanged; "" turns history off and needs acceptNoHistory: true.' ), }) .catchall(z.unknown()) - .describe('Complete updated Form.io form JSON definition'), + .describe('The updated form JSON'), 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.' + 'What changed against the live form, with no preamble ("Saved draft:", "Updated:")' ), 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}).' + "When true, save a draft instead of the live form. Only the draft fields of `form` are saved (components, settings, tags, properties, controller, esign, display), over any existing draft; form_get's output can be passed as is, and a changed non-draft field (title, path, access, …) is refused." ), - revert: z + acceptNoHistory: 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: resourceSegmentArgument('version') - .optional() - .describe( - 'Revision identifier for `revert: true` — either the revision `_vid` (e.g. "3") or the revision document `_id` (24-char hex).' + 'Set true only after the user agrees to save without revision history. Without it, such a save (no revisions licence, or the form has revisions off) is refused with HISTORY_NOT_ACCEPTED and nothing is written.' ), }, - outputSchema: formShape, + outputSchema: formDocument, annotations: overwrites('Update a form'), }, - async ({ cwd, formId, form: rawForm, note, draft, publish, revert, version }) => { + async ({ cwd, formId, form: rawForm, note, draft, acceptNoHistory }) => { 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 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, - }); + requireFormId(formId); + const cfg = await resolveToolConfig({ server, cwd, config }); - if (revert || publish || draft) { - const args = { formId, _vnote: note, cfg }; + if (draft) { + await requireRevisionsLicense(cfg, 'save a draft of this form'); return toMcpStructuredResult( - (await (revert && version - ? revertToRevision({ ...args, version }) - : publish - ? publishDraft(args) - : saveDraft({ ...args, form }))) as Record + (await saveDraft({ formId, form: rawForm, _vnote: note, cfg })) as Record< + string, + unknown + > ); } - // 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, + // The stored form is read only after the licence says the history check + // applies. The read needs a token and the probe does not, so reading beside the + // probe could start a browser login for a call the licence then refuses. + const { licensed, licenceUnknown, form } = await gateRevisionsLicense({ cfg, + actionLabel: 'update this form', + form: rawForm, + acceptNoHistory, }); + // An unknown licence is checked as a licensed one would be: skipping the check + // would let a form with history off be saved without the caller's decision. + if (licensed || licenceUnknown) { + await gateFormHistory({ cfg, formId, form: rawForm, acceptNoHistory }); + } return toMcpStructuredResult( (await formioFetch(`form/${formId}`, {}, cfg, { method: 'PUT', - body: { ...putBody, _vnote: prefixVnote(note) }, + body: { ...form, _vnote: prefixVnote(note) }, })) as Record ); } catch (error) { diff --git a/packages/mcp-server/src/tools/hello.ts b/packages/mcp-server/src/tools/hello.ts deleted file mode 100644 index 27958d1d..00000000 --- a/packages/mcp-server/src/tools/hello.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { toMcpStructuredResult } from '../mcp-responses.js'; -import { local } from '../tool-annotations.js'; - -export function registerHelloTool(server: McpServer) { - server.registerTool( - 'hello', - { - description: - 'Say hello — a connectivity check that confirms the server is running and reachable. Needs no Form.io project or credentials, so it is the first thing to try when other tools fail.', - inputSchema: { name: z.string().optional().describe('Name to greet') }, - outputSchema: { greeting: z.string().describe('The greeting this server produced') }, - annotations: local('Check the server is reachable', true), - }, - async ({ name }) => { - const greeting = `Hello from formio-mcp, ${name ?? 'world'}!`; - // The greeting itself is the point of the text view; the payload exists so - // a caller can read it without parsing prose. - return toMcpStructuredResult({ greeting }, greeting); - } - ); -} diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index 27a272e6..8f9dc296 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -5,15 +5,16 @@ import { registerActionDeleteTool } from './action_delete.js'; import { registerActionGetTool } from './action_get.js'; import { registerActionListTool } from './action_list.js'; import { registerActionTypeGetTool } from './action_type_get.js'; -import { registerActionTypesListTool } from './action_types_list.js'; +import { registerActionTypeListTool } from './action_type_list.js'; 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 { registerFormPublishTool } from './form_publish.js'; +import { registerFormRevertTool } from './form_revert.js'; import { registerFormRevisionGetTool } from './form_revision_get.js'; -import { registerFormRevisionsListTool } from './form_revisions_list.js'; +import { registerFormRevisionListTool } from './form_revision_list.js'; import { registerFormUpdateTool } from './form_update.js'; -import { registerHelloTool } from './hello.js'; import { registerProjectExportTool } from './project_export.js'; import { registerProjectGetTool } from './project_get.js'; import { registerProjectImportTool } from './project_import.js'; @@ -21,6 +22,7 @@ import { registerProjectSetTool } from './project_set.js'; import { registerRoleCreateTool } from './role_create.js'; import { registerRoleListTool } from './role_list.js'; import { registerRoleUpdateTool } from './role_update.js'; +import { registerServerStatusTool } from './server_status.js'; export interface RegisterAllToolsOptions { cwd?: () => string; @@ -31,13 +33,16 @@ export function registerAllTools( config: FormioConfig, options: RegisterAllToolsOptions = {} ) { - registerHelloTool(server); + // Reports the same resolution as project_get, so it takes the same cwd fallback. + registerServerStatusTool(server, config, { cwd: options.cwd }); registerFormCreateTool(server, config); registerFormGetTool(server, config); registerFormListTool(server, config); registerFormRevisionGetTool(server, config); - registerFormRevisionsListTool(server, config); + registerFormRevisionListTool(server, config); registerFormUpdateTool(server, config); + registerFormPublishTool(server, config); + registerFormRevertTool(server, config); registerProjectExportTool(server, config); // Reports what the tools around it will resolve, so it takes the same config // and the same cwd fallback project_set writes under. @@ -57,7 +62,7 @@ export function registerAllTools( registerRoleCreateTool(server, config); registerRoleListTool(server, config); registerRoleUpdateTool(server, config); - registerActionTypesListTool(server, config); + registerActionTypeListTool(server, config); registerActionTypeGetTool(server, config); registerActionCreateTool(server, config); registerActionListTool(server, config); diff --git a/packages/mcp-server/src/tools/list-contract.ts b/packages/mcp-server/src/tools/list-contract.ts new file mode 100644 index 00000000..68cd5f2d --- /dev/null +++ b/packages/mcp-server/src/tools/list-contract.ts @@ -0,0 +1,116 @@ +import { z } from 'zod'; +import { ResolvedFormioConfig } from '../config.js'; +import { formioFetch } from '../formio-client.js'; + +/** + * The list contract every paged list tool shares. + * + * Form.io's index routes default to 10 items and impose no maximum, so `limit` and + * `skip` are sent on every request: left to Form.io's default, a project with 11 + * roles came back with 10 and nothing said so. The total comes from the response's + * Content-Range header, so `hasMore` says whether another page exists; where Form.io + * reports none, `total` is absent and a full page means `hasMore`. + */ +export const DEFAULT_LIST_LIMIT = 100; + +export interface ListArgumentDefaults { + /** The `sort` sent when the caller gives none. */ + sort?: string; + /** The `select` sent when the caller gives none. */ + select?: string; +} + +/** The paging arguments, described with the defaults this tool applies. */ +export function listArguments(defaults: ListArgumentDefaults = {}) { + return { + limit: z.number().int().positive().default(DEFAULT_LIST_LIMIT).describe('Page size'), + skip: z.number().int().nonnegative().default(0).describe('Items to skip; page while `hasMore`'), + sort: z + .string() + .optional() + .describe( + `Form.io sort, e.g. "-modified" for newest first${defaults.sort ? ` (default: ${defaults.sort})` : ''}` + ), + select: z + .string() + .optional() + .describe( + `Comma-separated fields to return${defaults.select ? ` (default: ${defaults.select})` : ''}` + ), + }; +} + +export interface ListQuery { + limit: number; + skip: number; + sort?: string; + select?: string; +} + +export interface ListPage { + items: T[]; + /** The total Form.io reported; absent when it reported none. */ + total?: number; + hasMore: boolean; +} + +export interface PageOfRequest { + items: T[]; + /** The total Form.io reported; absent when it reported none. */ + total: number | undefined; + skip: number; + /** The page size requested. */ + limit: number; +} + +/** + * A page and where it sits. Without a reported total nothing says how many exist, so + * `total` is left out rather than guessed, and a full page may have more behind it — + * so `hasMore` then says whether the page came back full. + */ +export function pageOf({ items, total, skip, limit }: PageOfRequest): ListPage { + if (total === undefined) { + return { items, hasMore: items.length === limit }; + } + return { items, total, hasMore: skip + items.length < total }; +} + +/** + * A page as a tool result: the items under the tool's own key, beside `hasMore` and, + * when Form.io reported one, `total`. + */ +export function listResult(key: string, page: ListPage): Record { + return { + [key]: page.items, + ...(page.total === undefined ? {} : { total: page.total }), + hasMore: page.hasMore, + }; +} + +export interface FetchListPageRequest { + path: string; + query: ListQuery; + config: ResolvedFormioConfig; + defaults?: ListArgumentDefaults; + /** Tool-specific query filters, sent beside the paging parameters. */ + filters?: Record; +} + +export async function fetchListPage({ + path, + query, + config, + defaults = {}, + filters = {}, +}: FetchListPageRequest): Promise>> { + const params = { + ...filters, + limit: String(query.limit), + skip: String(query.skip), + sort: query.sort ?? defaults.sort, + select: query.select ?? defaults.select, + }; + const { data, total } = await formioFetch(path, params, config, { withMeta: true }); + const items = Array.isArray(data) ? (data as Record[]) : []; + return pageOf({ items, total, skip: query.skip, limit: query.limit }); +} diff --git a/packages/mcp-server/src/tools/object-id.ts b/packages/mcp-server/src/tools/object-id.ts new file mode 100644 index 00000000..60e85a49 --- /dev/null +++ b/packages/mcp-server/src/tools/object-id.ts @@ -0,0 +1,25 @@ +import { MONGO_ID_PATTERN } from '../formio-client.js'; +import { ToolError } from '../tool-errors.js'; + +export interface RequireObjectIdOptions { + /** The argument's name, as the caller passed it. */ + argument: string; + value: string; + /** What to pass instead, and where to read it. */ + remedy: string; +} + +/** + * Refuses an argument that is not a 24-character ObjectId, before any request is made. + * + * Checked in the handler rather than in the schema, so the refusal carries the + * `INVALID_ARGUMENT` code: a schema failure is reported by the SDK as bare text. + */ +export function requireObjectId({ argument, value, remedy }: RequireObjectIdOptions): void { + if (!MONGO_ID_PATTERN.test(value)) { + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: `${argument} ${JSON.stringify(value)} is not accepted: ${remedy}`, + }); + } +} diff --git a/packages/mcp-server/src/tools/project-resolution.ts b/packages/mcp-server/src/tools/project-resolution.ts new file mode 100644 index 00000000..68bbc407 --- /dev/null +++ b/packages/mcp-server/src/tools/project-resolution.ts @@ -0,0 +1,117 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { FormioConfig, ResolvedFormioConfig } from '../config.js'; +import { ProjectReport, reportProject } from '../project-report.js'; +import { resolveProjectConfig } from '../project-resolver.js'; +import { + WorkingDirectory, + guessedDirectory, + isNamedDirectory, + workspaceDirectory, +} from '../workspace-directory.js'; +import { TOOL_REMEDIES } from './project-remedies.js'; + +export interface ToolDirectoryRequest { + server: McpServer; + /** The cwd the caller passed, if any. */ + cwd: string | undefined; + /** The server's own process directory, the last source tried. */ + serverCwd?: () => string; +} + +/** + * The directory a tool call resolves against: the caller's cwd, else the client's + * workspace root, else CLAUDE_PROJECT_DIR, else the server's own directory. + */ +export function toolDirectory({ + server, + cwd, + serverCwd, +}: ToolDirectoryRequest): Promise { + return workspaceDirectory(server).resolve({ cwd, serverCwd }); +} + +export interface ToolConfigRequest { + server: McpServer; + cwd: string | undefined; + config: FormioConfig; +} + +/** The project a project-scoped tool targets, for the directory its call resolves against. */ +export async function resolveToolConfig({ + server, + cwd, + config, +}: ToolConfigRequest): Promise { + const directory = await toolDirectory({ server, cwd }); + return resolveProjectConfig(directory.dir, config, { + cwdWasNamed: isNamedDirectory(directory), + guessedDirectory: guessedDirectory(directory), + }); +} + +export interface ToolProjectReportRequest { + /** The directory the call resolves against, and which source supplied it. */ + directory: WorkingDirectory; + config: FormioConfig; + /** + * Caller-owned and appended to in place, so a note survives a report that cannot + * answer: an "Ignoring " note emitted while walking is often the first half + * of the story a later throw finishes, and the caller renders both. + */ + notes: string[]; +} + +/** + * The resolution a tool reports for a caller's directory — the one answer + * project_get and server_status both give, from the resolver every other tool uses. + */ +export function reportProjectForTool({ + directory, + config, + notes, +}: ToolProjectReportRequest): ProjectReport { + return reportProject({ + notes, + cwd: directory.dir, + baseConfig: config, + remedies: TOOL_REMEDIES, + // A guessed directory — the server's own, fixed at spawn, or a launch default — + // makes the answer say so and withhold a remedy keyed to it. A directory the + // caller or the client named is where the user is working. + cwdWasNamed: isNamedDirectory(directory), + guessedDirectory: guessedDirectory(directory), + }); +} + +/** The report as a structured payload, omitting the halves it does not have. */ +export function projectReportPayload( + report: ProjectReport, + directory: WorkingDirectory +): Record { + return { + status: report.status, + cwd: report.cwd, + cwdSource: directory.source, + ...(report.projectUrl ? { projectUrl: report.projectUrl } : {}), + ...(report.baseUrl ? { baseUrl: report.baseUrl } : {}), + ...(report.projectUrlSource ? { projectUrlSource: report.projectUrlSource } : {}), + ...(report.baseUrlSource ? { baseUrlSource: report.baseUrlSource } : {}), + ...(report.forced ? { forced: true } : {}), + shadowed: report.shadowed, + unpaired: report.unpaired, + ...(report.remedy ? { remedy: report.remedy } : {}), + message: report.message, + notes: report.notes, + }; +} + +/** + * The report as text. Notes lead the message, exactly as the CLI prints them. They + * are not colour: an "Ignoring FORMIO_BASE_URL: …" note is the CAUSE of a + * base-url-unresolved answer, and the server's-own-directory note is the reason an + * `ok` answer may be about the wrong project. Left in structuredContent alone they + * vanish in every client that surfaces only text. + */ +export function projectReportText(report: ProjectReport): string { + return [...report.notes, report.message].filter(Boolean).join('\n'); +} diff --git a/packages/mcp-server/src/tools/project_export.ts b/packages/mcp-server/src/tools/project_export.ts index 4bf94315..a4d2c5ea 100644 --- a/packages/mcp-server/src/tools/project_export.ts +++ b/packages/mcp-server/src/tools/project_export.ts @@ -2,25 +2,26 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { templateShape } from '../output-schemas.js'; +import { templateDocument } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; export function registerProjectExportTool(server: McpServer, config: FormioConfig) { server.registerTool( 'project_export', { description: - "Export the complete template (roles, resources, forms, actions) of the Form.io project mapped to the user's current working directory as a portable JSON document. Use this to snapshot a project before importing changes.", + 'Export the project `cwd` resolves to as a template (roles, resources, forms, actions) — the snapshot to take before project_import.', inputSchema: { cwd: cwdSchema, }, - outputSchema: templateShape, + outputSchema: templateDocument, annotations: reads('Export the project template'), }, async ({ cwd }) => { try { - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); const template = (await formioFetch('export', {}, cfg)) as Record; return toMcpStructuredResult(template); } catch (error) { diff --git a/packages/mcp-server/src/tools/project_get.ts b/packages/mcp-server/src/tools/project_get.ts index 949881c5..f8b03ea2 100644 --- a/packages/mcp-server/src/tools/project_get.ts +++ b/packages/mcp-server/src/tools/project_get.ts @@ -1,11 +1,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { FormioConfig } from '../config.js'; import { toMcpError, toMcpStructuredResult } from '../mcp-responses.js'; -import { projectResolutionShape } from '../output-schemas.js'; +import { projectResolutionOutput } from '../output-schemas.js'; import { cwdSchema } from '../project-resolver.js'; -import { reportProject } from '../project-report.js'; import { local } from '../tool-annotations.js'; -import { TOOL_REMEDIES } from './project-remedies.js'; +import { + projectReportPayload, + projectReportText, + reportProjectForTool, + toolDirectory, +} from './project-resolution.js'; export interface ProjectGetOptions { cwd?: () => string; @@ -21,57 +25,22 @@ export function registerProjectGetTool( 'project_get', { description: [ - 'Report which Form.io project the given working directory resolves to, which deployment hosts it, and which layer supplied each — the preflight to run before the first tool call that reads from or writes to a deployment.', - 'Answers from inside this server, using the same resolver every other tool uses, so what it reports is what the next call targets. There is no need to run any shell command to ask this.', - 'Branch on `status`. "ok" means both URLs resolved and you may proceed. "not-configured" means nothing is mapped for this directory: relay the message, ask the user for the single value it names, record it with project_set, and call this again. "base-url-unresolved" means the project IS recorded and only its deployment is missing — ask for the Base URL alone and do NOT re-ask for the Project URL.', - 'Reads only. It resolves and reports; project_set is what records a choice.', + 'Report which Form.io project the working directory resolves to, which deployment hosts it, and which layer supplied each — the preflight before the first call that reads from or writes to a deployment. It uses the resolver every tool uses, so no shell command is needed.', + 'Branch on `status`: "ok" — proceed. "not-configured" — relay the message, ask the user for the one value it names, record it with project_set, and call this again. "base-url-unresolved" — the project IS recorded: ask for the Base URL alone, never the Project URL again.', + 'Reads only; project_set records a choice.', ].join(' '), inputSchema: { cwd: cwdSchema }, - outputSchema: projectResolutionShape, - annotations: local('Report the project this directory resolves to', true), + outputSchema: projectResolutionOutput, + annotations: local('Report the active project', true), }, async ({ cwd }) => { - // Owned here so a note survives a report that cannot answer: an "Ignoring - // " note emitted while walking is often the first half of the story a - // later throw finishes, and the catch below renders both. const notes: string[] = []; try { - const report = reportProject({ - notes, - // The server's own process cwd is fixed at spawn and may be mapped to a - // different project, which is why cwd is asked for on every call. It is - // still the documented fallback: project_set writes under the same key. - cwd: cwd ?? getServerCwd(), - baseConfig: config, - remedies: TOOL_REMEDIES, - // So the unmapped answer can say which directory it actually searched. - // Its remedy names a cwd to record the project under, and the server's - // own is the one directory recording it under would not help. - cwdWasNamed: cwd !== undefined, - }); + const directory = await toolDirectory({ server, cwd, serverCwd: getServerCwd }); + const report = reportProjectForTool({ directory, config, notes }); return toMcpStructuredResult( - { - status: report.status, - cwd: report.cwd, - ...(report.projectUrl ? { projectUrl: report.projectUrl } : {}), - ...(report.baseUrl ? { baseUrl: report.baseUrl } : {}), - ...(report.projectUrlSource ? { projectUrlSource: report.projectUrlSource } : {}), - ...(report.baseUrlSource ? { baseUrlSource: report.baseUrlSource } : {}), - ...(report.forced ? { forced: true } : {}), - shadowed: report.shadowed, - unpaired: report.unpaired, - ...(report.remedy ? { remedy: report.remedy } : {}), - message: report.message, - notes: report.notes, - }, - // Notes lead the message, exactly as the CLI prints them. They are not - // colour: an "Ignoring FORMIO_BASE_URL: …" note is the CAUSE of a - // base-url-unresolved answer, and the server's-own-directory note is the - // reason an `ok` answer may be about the wrong project. Left in - // structuredContent alone they vanish in every client that surfaces only - // text, which showed the user a bare "could not be determined" about a - // variable that had just been discarded unread. - [...report.notes, report.message].filter(Boolean).join('\n') + projectReportPayload(report, directory), + projectReportText(report) ); } catch (error) { // "Could not answer at all" — an unreadable map, a formio.json that will diff --git a/packages/mcp-server/src/tools/project_import.ts b/packages/mcp-server/src/tools/project_import.ts index a9f4fea5..61d81d6a 100644 --- a/packages/mcp-server/src/tools/project_import.ts +++ b/packages/mcp-server/src/tools/project_import.ts @@ -3,26 +3,27 @@ import { z } from 'zod'; import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpError, toMcpStructuredResult } from '../mcp-responses.js'; -import { acknowledgementShape } from '../output-schemas.js'; +import { acknowledgementOutput } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; export function registerProjectImportTool(server: McpServer, config: FormioConfig) { server.registerTool( 'project_import', { description: - "Import a template JSON into the existing Form.io project mapped to the user's current working directory, merging roles, resources, forms, and actions in one call. Use the formio-resource-planner skill to construct the template before calling this tool. WARNING: import merges into the existing project — use project_export first to snapshot.", + "Merge a template's roles, resources, forms and actions into the existing project `cwd` resolves to, in one call. Build the template with the formio-resource-planner skill, and snapshot first with project_export: the import merges, it does not replace.", inputSchema: { cwd: cwdSchema, template: z.looseObject({}).describe('The template JSON object to import'), }, - outputSchema: acknowledgementShape, + outputSchema: acknowledgementOutput, annotations: overwrites('Import a project template'), }, async ({ cwd, template }) => { try { - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); const result = await formioFetch('import', {}, cfg, { method: 'POST', body: { template }, diff --git a/packages/mcp-server/src/tools/project_set.ts b/packages/mcp-server/src/tools/project_set.ts index 6532c5b0..a6be3a90 100644 --- a/packages/mcp-server/src/tools/project_set.ts +++ b/packages/mcp-server/src/tools/project_set.ts @@ -19,9 +19,12 @@ import { import { projectCommand } from '../cli-launch.js'; import { cwdSchema } from '../project-resolver.js'; import { FORCED_PAIR_FACT, ProjectReport, reportProject } from '../project-report.js'; -import { toMcpStructuredResult } from '../mcp-responses.js'; -import { projectMappingShape } from '../output-schemas.js'; +import { catchToolErrors, toMcpStructuredResult } from '../mcp-responses.js'; +import { ToolError, ToolErrorCode } from '../tool-errors.js'; +import { projectMappingOutput } from '../output-schemas.js'; import { local } from '../tool-annotations.js'; +import { toolDirectory } from './project-resolution.js'; +import { guessedDirectory, isNamedDirectory } from '../workspace-directory.js'; import { readProjectEntryForWrite, unusableRecordProjectUrl, @@ -64,45 +67,56 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt server.registerTool( 'project_set', { + // Rules a caller acts on, and nothing else: why the tool works this way is in + // the package README ("Why project_set works this way"). description: [ - 'Set the active Form.io project for the given working directory by recording its URL in ~/.formio/projects.json', - 'You MUST call this tool whenever the user asks to set, change, or switch the active Form.io project — do not merely acknowledge the request in text. Persisting the choice requires the tool call.', - "The chosen URL is persisted to ~/.formio/projects.json keyed by the cwd argument when provided (or the MCP server process cwd otherwise). Pass the `cwd` argument whenever you know the user's current working directory — the server process cwd is fixed at spawn and may not match where the user actually is.", - 'Every Form.io tool resolves its project on each call, so a mapping written here needs no restart. It takes effect only where the mapping is the record that WINS, though: under a committed formio.json the mapping is the fallback if that file goes away, and this call reports the pair that actually resolves rather than the one it recorded. Read `ok` and `projectUrl` on the result rather than assuming the write governs.', - 'You normally pass only projectUrl. The base URL — which builds the portal-login URL and keys the cached token — is derived from it — https://api.form.io for a project on a form.io host, and the parent path for a project addressed as a sub-directory — so there is nothing to supply. Pass baseUrl ONLY when the server reports that it cannot be determined, which happens for a project URL that carries no path on a customer domain: there the deployment is a sibling sub-domain and nothing in the project URL names it. Do not ask the user for a base URL before the server says it needs one.', - `This tool writes the machine-local mapping, which is keyed by absolute path and therefore does not survive a clone. To record the target with the code instead — versioned, visible in a diff, and shared with everyone who clones the repository — write a committed ${COMMITTED_CONFIG_FILE} yourself, in the application's own folder: a JSON object holding {"projectUrl": "..."}, plus "baseUrl" only when it cannot be derived. This server reads that file and never writes it.`, - `Resolution is by scope, narrowest first, and precedence runs: a committed ${COMMITTED_CONFIG_FILE}, then the working-directory mapping, then FORMIO_PROJECT_URL in the environment, which is the weakest of the three. So a mapping written here DOES override an environment value, and a committed file overrides both.`, + 'Set the active Form.io project for a directory: records its Project URL in ~/.formio/projects.json, keyed by the directory `cwd` resolves to. Call it whenever the user asks to set, change, or switch the project; acknowledging in text persists nothing. No restart is needed.', + `Resolution precedence: a committed ${COMMITTED_CONFIG_FILE}, then this mapping, then FORMIO_PROJECT_URL in the environment, the weakest — so a mapping written here DOES override the environment. \`ok\` and \`projectUrl\` on the result report the pair that resolves.`, + 'Pass projectUrl alone: the Base URL is derived — https://api.form.io for a form.io host, the parent path for a sub-directory project. Pass baseUrl only when the server reports it cannot be derived.', + `A committed ${COMMITTED_CONFIG_FILE} ({"projectUrl": "..."}, plus "baseUrl" only when it cannot be derived) is written by hand in the application's folder; this server reads it and never writes it.`, ].join(' '), // Strict: an argument this tool does not take is REFUSED, not silently dropped. // `scope` was removed with the committed-file writer, and the previous release's // own documentation still names it — stripped, that call would write the // machine-local mapping and report success for a committed write that never // happened. The CLI whitelists its flags for exactly this reason. + // + // The URLs are plain strings here and checked in the handler, so a value that is + // not an http(s) URL is refused as INVALID_ARGUMENT: a schema failure is + // reported by the SDK as bare text with no code. inputSchema: z.strictObject({ projectUrl: z - .url({ protocol: /^https?$/ }) + .string() .optional() .describe( - 'Full URL of the Form.io project to activate. Optional when THIS DIRECTORY\'S OWN MAPPING already holds a project: omitting it then updates that record\'s baseUrl alone, which is how the "Base URL cannot be determined" error is repaired without re-asking for a project URL. Where the project is held by another record, a baseUrl alone is refused rather than split from its project — for a committed formio.json the deployment is added to that file by hand (this server never writes one), and for FORMIO_PROJECT_URL in the environment the call must carry BOTH projectUrl and baseUrl, which records the pair here. Required when nothing configures a project at all. On the Form.io hosted cloud it is the project name as a sub-domain of form.io, e.g. https://examples.form.io — never https://api.form.io, which is the Base URL every hosted project shares. On a customer-hosted deployment it is either a sibling sub-domain of that customer’s domain, e.g. https://myproject.mysite.com, or a sub-directory of the deployment, e.g. https://forms.mysite.com/myproject — whichever that deployment uses.' + "Full Project URL, e.g. https://examples.form.io or https://forms.mysite.com/myproject; never https://api.form.io. Omit it only to add a baseUrl to THIS DIRECTORY'S OWN mapping: where a committed formio.json holds the project, add baseUrl to that file by hand; where FORMIO_PROJECT_URL does, pass both." ), // The SAME schema every reader validates against. A write must not accept // what a read cannot key on. cwd: cwdSchema.describe( - "User's current working directory to key the persisted mapping against, as an absolute path. Pass whenever known (e.g. from UserPromptSubmit hook context). Falls back to the MCP server's process.cwd() when omitted, which is fixed at spawn and may not be where the user is." + "Optional. Directory whose mapping to write; defaults to the client's workspace root." ), baseUrl: z - .url({ protocol: /^https?$/ }) + .string() .optional() .describe( - 'Deployment URL for the Form.io Enterprise Server that hosts this project. It builds the portal-login URL and keys the cached token, so a wrong one fails at login rather than on the request. Usually omitted: it is derived from projectUrl wherever it can be. Supply it when the server reports that it cannot be determined — a project URL with no path on a customer domain, whose deployment is a sibling sub-domain. It MAY carry a path of its own when the deployment is mounted at a sub-path. Never pass it for a project on a form.io host: those are served by https://api.form.io and by nothing else, so any other value is refused. Persisted per-cwd alongside the project URL, and bound to the project recorded with it, so each directory can target a different deployment and no deployment answers for another project. When omitted and this call does not change the project, the base URL already mapped for this directory is kept — but a call that re-points the directory to a different project keeps nothing, because that value belonged to the project being replaced.' + "The deployment hosting the project; omit it unless the server reports it cannot be derived. May carry a sub-path; refused for a form.io host. Omitted, this directory's mapped value is kept unless the call re-points it to a different project." ), }), - outputSchema: projectMappingShape, + outputSchema: projectMappingOutput, // Writes only to the local project map — no Form.io request involved. annotations: local('Set the active project', false), }, - async ({ projectUrl, cwd, baseUrl: baseUrlArg }) => { - const entryCwd = cwd ?? getServerCwd(); + catchToolErrors(async ({ projectUrl, cwd, baseUrl: baseUrlArg }) => { + // The same directory every reader resolves for a call like this one: the + // caller's cwd, else the client's workspace root, else CLAUDE_PROJECT_DIR, else + // the server's own. A mapping keyed anywhere else is one the next call misses. + const directory = await toolDirectory({ server, cwd, serverCwd: getServerCwd }); + const entryCwd = directory.dir; + // A guessed directory — the server's own, or a launch default — is one nobody + // chose for this write. + const named = isNamedDirectory(directory); + const guessed = guessedDirectory(directory); const mapped = readProjectEntryForWrite(entryCwd); // An entry that EXISTS and cannot be honoured is not an absent one. A record's // URLs are validated where that record WINS — inside the resolver — so a mapping @@ -137,16 +151,34 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt // and the write it invites is one nothing later reads. project_get answers the // same state by omitting its remedy; this is that answer in a writer's // vocabulary. - const recordUnder = cwd ? `cwd ${entryCwd}` : "cwd set to the user's own directory"; - const fallbackCwdWarning = cwd + const recordUnder = named ? `cwd ${entryCwd}` : "cwd set to the user's own directory"; + const fallbackCwdWarning = named ? '' - : ` Note: no cwd argument was passed, so ${entryCwd} is the MCP server's own working directory rather than the user's. Call project_set again with cwd set to the user's directory BEFORE recording anything — a record written here would not be found from theirs.`; + : ` Note: no cwd argument was passed and the client named no directory, so ${entryCwd} is ${guessed}, which may not be the user's. Call project_set again with cwd set to the user's directory BEFORE recording anything — a record written here would not be found from theirs.`; // Annotated on the variable so TypeScript narrows after a call: an arrow // returning `never` only terminates control flow for the checker when the // binding itself declares that type. - const refuse: (message: string) => never = (message) => { - throw new Error([...walkNotes, message + fallbackCwdWarning].join('\n')); + // A refusal of the caller's arguments is INVALID_ARGUMENT; a failure the reader + // raised keeps the code it was raised with. + const refuse: (message: string, code?: ToolErrorCode) => never = ( + message, + code = 'INVALID_ARGUMENT' + ) => { + throw new ToolError({ + code, + message: [...walkNotes, message + fallbackCwdWarning].join('\n'), + }); }; + // An empty value is a value, not an omission: read as absent, it would keep the + // mapped value and report success for a write the caller did not ask for. + const emptyArguments = Object.entries({ projectUrl, baseUrl: baseUrlArg }) + .filter(([, value]) => value !== undefined && value.trim() === '') + .map(([name]) => name); + if (emptyArguments.length > 0) { + refuse( + `${emptyArguments.join(' and ')} ${emptyArguments.length === 1 ? 'is' : 'are'} empty. Omit an argument to leave it unchanged, or pass a full http(s) URL.` + ); + } const committed = findCommittedConfig(entryCwd, { onNote: (message) => walkNotes.push(message), }); @@ -259,9 +291,9 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt // not the user's directory. Keying there still beats refusing — some clients have // no cwd to pass — but the caller has to be told, or the next call that does pass // a cwd misses the mapping and loops. - const serverCwdWarning = cwd + const serverCwdWarning = named ? '' - : ` Warning: no cwd argument was passed, so this mapping is keyed to the MCP server's own working directory. If that is not the user's directory, call project_set again with cwd set to it.`; + : ` Warning: no cwd argument was passed and the client named no directory, so this mapping is keyed to ${entryCwd}, ${guessed}. If that is not the user's directory, call project_set again with cwd set to it.`; // A mapping written under a committed file naming a different project still // belongs on disk — it is the fallback if that file goes away — but it does not // take effect now. @@ -311,7 +343,8 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt baseConfig: { projectUrl: getEnvProjectUrl(), baseUrl: getEnvBaseUrl() }, remedies: TOOL_REMEDIES, notes: reportNotes, - cwdWasNamed: Boolean(cwd), + cwdWasNamed: named, + guessedDirectory: guessed, }); } catch (error) { // NOT swallowed. A committed file is checked for shape where it is read and @@ -321,7 +354,10 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt // was written" instead, this returned a success naming a pair the governing // file contradicts, and the next call failed with the reason discarded. keepNotes(); - refuse(error instanceof Error ? error.message : String(error)); + refuse( + error instanceof Error ? error.message : String(error), + error instanceof ToolError ? error.code : 'INTERNAL' + ); } keepNotes(); return report; @@ -398,6 +434,6 @@ export function registerProjectSetTool(server: McpServer, options: ProjectSetOpt }, fullMessage ); - } + }) ); } diff --git a/packages/mcp-server/src/tools/role-schema.ts b/packages/mcp-server/src/tools/role-schema.ts index 97750d3e..075fc9a6 100644 --- a/packages/mcp-server/src/tools/role-schema.ts +++ b/packages/mcp-server/src/tools/role-schema.ts @@ -1,11 +1,16 @@ import { z } from 'zod'; -export const roleFields = { - title: z.string().optional().describe('Role title'), - description: z.string().optional().describe('Role description'), - default: z - .boolean() - .optional() - .describe('When true, role is assigned to every new authenticated user'), - admin: z.boolean().optional().describe('When true, holders bypass access checks'), +// Form.io's own fields, documented by the formio-schema skill, so they carry no +// description here. +const roleFields = { + title: z.string().optional(), + description: z.string().optional(), + default: z.boolean().optional(), + admin: z.boolean().optional(), }; + +/** A role document, as role_update sends it. */ +export const roleSchema = z.object(roleFields).catchall(z.unknown()); + +/** A role document to create: the same shape, with its title required. */ +export const newRoleSchema = roleSchema.extend({ title: z.string() }); diff --git a/packages/mcp-server/src/tools/role_create.ts b/packages/mcp-server/src/tools/role_create.ts index 83a875b4..7a34d01c 100644 --- a/packages/mcp-server/src/tools/role_create.ts +++ b/packages/mcp-server/src/tools/role_create.ts @@ -1,30 +1,28 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; import { FormioConfig } from '../config.js'; import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { roleShape } from '../output-schemas.js'; +import { roleDocument } from '../output-schemas.js'; import { creates } from '../tool-annotations.js'; -import { roleFields } from './role-schema.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { newRoleSchema } from './role-schema.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; export function registerRoleCreateTool(server: McpServer, config: FormioConfig) { server.registerTool( 'role_create', { - description: - "Create a new role in the Form.io project mapped to the user's current working directory.", + description: 'Create a new role in the project `cwd` resolves to.', inputSchema: { cwd: cwdSchema, - ...roleFields, - title: z.string().describe('Role title'), + role: newRoleSchema.describe('The role to create'), }, - outputSchema: roleShape, + outputSchema: roleDocument, annotations: creates('Create a role'), }, - async ({ cwd, ...role }) => { + async ({ cwd, role }) => { try { - const cfg = resolveProjectConfig(cwd, config); + const cfg = await resolveToolConfig({ server, cwd, config }); const created = (await formioFetch('role', {}, cfg, { method: 'POST', body: role, diff --git a/packages/mcp-server/src/tools/role_list.ts b/packages/mcp-server/src/tools/role_list.ts index eb66c0c1..98e43990 100644 --- a/packages/mcp-server/src/tools/role_list.ts +++ b/packages/mcp-server/src/tools/role_list.ts @@ -1,31 +1,29 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; import { FormioConfig } from '../config.js'; -import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { rolesListShape } from '../output-schemas.js'; +import { rolesListOutput } from '../output-schemas.js'; import { reads } from '../tool-annotations.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { fetchListPage, listArguments, listResult } from './list-contract.js'; export function registerRoleListTool(server: McpServer, config: FormioConfig) { server.registerTool( 'role_list', { - description: - "List all roles defined in the Form.io project mapped to the user's current working directory.", + description: 'List the roles defined in the project `cwd` resolves to, one page at a time.', inputSchema: { cwd: cwdSchema, - select: z.string().optional().describe('Comma-separated fields to return'), + ...listArguments(), }, - outputSchema: rolesListShape, + outputSchema: rolesListOutput, annotations: reads('List roles'), }, - async ({ cwd, select }) => { + async ({ cwd, ...query }) => { try { - const cfg = resolveProjectConfig(cwd, config); - const params: Record = { select }; - const roles = (await formioFetch('role', params, cfg)) as Record[]; - return toMcpStructuredResult({ roles, count: roles.length }); + const cfg = await resolveToolConfig({ server, cwd, config }); + const page = await fetchListPage({ path: 'role', query, config: cfg }); + return toMcpStructuredResult(listResult('roles', page)); } catch (error) { return toMcpError(error); } diff --git a/packages/mcp-server/src/tools/role_update.ts b/packages/mcp-server/src/tools/role_update.ts index 0c433f65..b2b3380b 100644 --- a/packages/mcp-server/src/tools/role_update.ts +++ b/packages/mcp-server/src/tools/role_update.ts @@ -1,36 +1,37 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { FormioConfig } from '../config.js'; -import { formioFetch, MONGO_ID_PATTERN } from '../formio-client.js'; +import { formioFetch } from '../formio-client.js'; import { toMcpStructuredResult, toMcpError } from '../mcp-responses.js'; -import { roleShape } from '../output-schemas.js'; +import { roleDocument } from '../output-schemas.js'; import { overwrites } from '../tool-annotations.js'; -import { roleFields } from './role-schema.js'; -import { cwdSchema, resolveProjectConfig } from '../project-resolver.js'; +import { roleSchema } from './role-schema.js'; +import { cwdSchema } from '../project-resolver.js'; +import { resolveToolConfig } from './project-resolution.js'; +import { requireObjectId } from './object-id.js'; export function registerRoleUpdateTool(server: McpServer, config: FormioConfig) { server.registerTool( 'role_update', { description: - "Update an existing role in the Form.io project mapped to the user's current working directory. This is a full replacement — include all fields you want to preserve.", + 'Update an existing role in the project `cwd` resolves to. Fields sent overwrite the stored ones (an array or object is stored as given, not merged); top-level fields left out keep their stored value.', inputSchema: { cwd: cwdSchema, - roleId: z - .string() - .regex(MONGO_ID_PATTERN, 'Must be a 24-character MongoDB ObjectId') - .describe('The _id of the role to update'), - role: z - .object(roleFields) - .catchall(z.unknown()) - .describe('Role document with updated fields'), + roleId: z.string().describe('The _id of the role to update, a 24-character ObjectId'), + role: roleSchema.describe('Role document with updated fields'), }, - outputSchema: roleShape, + outputSchema: roleDocument, annotations: overwrites('Update a role'), }, async ({ cwd, roleId, role }) => { try { - const cfg = resolveProjectConfig(cwd, config); + requireObjectId({ + argument: 'roleId', + value: roleId, + remedy: "roleId takes a role's 24-character ObjectId _id. Read it with role_list.", + }); + const cfg = await resolveToolConfig({ server, cwd, config }); const updated = (await formioFetch(`role/${roleId}`, {}, cfg, { method: 'PUT', body: role, diff --git a/packages/mcp-server/src/tools/server_status.ts b/packages/mcp-server/src/tools/server_status.ts new file mode 100644 index 00000000..14fcffcc --- /dev/null +++ b/packages/mcp-server/src/tools/server_status.ts @@ -0,0 +1,55 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { SERVER_NAME, SERVER_VERSION } from '../cli-launch.js'; +import { FormioConfig } from '../config.js'; +import { toMcpError, toMcpStructuredResult } from '../mcp-responses.js'; +import { serverStatusOutput } from '../output-schemas.js'; +import { cwdSchema } from '../project-resolver.js'; +import { local } from '../tool-annotations.js'; +import { + projectReportPayload, + projectReportText, + reportProjectForTool, + toolDirectory, +} from './project-resolution.js'; + +export interface ServerStatusOptions { + cwd?: () => string; +} + +export function registerServerStatusTool( + server: McpServer, + config: FormioConfig, + options: ServerStatusOptions = {} +) { + const getServerCwd = options.cwd ?? (() => process.cwd()); + server.registerTool( + 'server_status', + { + description: + "Report this 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: call it first when other tools fail.", + inputSchema: { cwd: cwdSchema }, + outputSchema: serverStatusOutput, + annotations: local('Report server status', true), + }, + async ({ cwd }) => { + const notes: string[] = []; + const heading = `${SERVER_NAME} ${SERVER_VERSION}`; + try { + const directory = await toolDirectory({ server, cwd, serverCwd: getServerCwd }); + const report = reportProjectForTool({ directory, config, notes }); + return toMcpStructuredResult( + { + name: SERVER_NAME, + version: SERVER_VERSION, + ...projectReportPayload(report, directory), + }, + [heading, projectReportText(report)].join('\n') + ); + } catch (error) { + // A record that cannot be read is not "nothing configured"; it fails as + // project_get does, naming the server that answered. + return toMcpError(error, [heading, ...notes]); + } + } + ); +} diff --git a/packages/mcp-server/src/workspace-directory.ts b/packages/mcp-server/src/workspace-directory.ts new file mode 100644 index 00000000..9a24d5ca --- /dev/null +++ b/packages/mcp-server/src/workspace-directory.ts @@ -0,0 +1,280 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { RootsListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { + COMMITTED_CONFIG_FILE, + CommittedConfigUnusableError, + findCommittedConfig, +} from './committed-config.js'; +import { readProjectEntryForWrite } from './project-map.js'; +import { ToolError } from './tool-errors.js'; + +/** + * Which source supplied the directory a call resolves against, in the order they are + * tried: the caller's `cwd` argument, the client's workspace roots, the + * CLAUDE_PROJECT_DIR environment variable, and last the server process's own working + * directory — fixed wherever the client spawned it, which is often not where the user + * is working. + */ +export const CWD_SOURCES = ['argument', 'client-root', 'claude-project-dir', 'server'] as const; +export type CwdSource = (typeof CWD_SOURCES)[number]; + +export interface WorkingDirectory { + dir: string; + source: CwdSource; +} + +/** How long a `roots/list` request may take before it counts as no roots. */ +export const ROOTS_TIMEOUT_MS = 2_000; + +// Read leniently, root by root. The SDK's own result schema (`listRoots`) requires +// every uri to start with file://, so a single root of another scheme would discard +// the whole answer — the file:// roots beside it included — where it is that one root +// that names no directory. +const RootsAnswer = z.looseObject({ + roots: z.array(z.looseObject({ uri: z.string() })), +}); + +/** + * The directories behind a client's roots. Only a `file://` URI names a directory; any + * other root, or one `fileURLToPath` cannot convert, is not one this server can + * resolve a project for, so it is left out rather than refused. Each path is + * normalised before duplicates are dropped, so `/work/app/` and `/work/app` are one + * root, as they are one key in the project map. + */ +export function rootDirectories(roots: ReadonlyArray<{ uri: string }>): string[] { + const dirs = roots.flatMap(({ uri }) => { + if (!uri.startsWith('file://')) { + return []; + } + try { + return [path.resolve(fileURLToPath(uri))]; + } catch { + return []; + } + }); + return [...new Set(dirs)]; +} + +/** + * The record a directory resolves its project from, when it has one of its own. + * + * `key` is the record's identity: two directories with the same key resolve the same + * record — two roots inside one repository both walk up to its formio.json — and so + * the same project. Records are compared rather than project URLs because a record + * carries its own deployment too: two files naming one project URL can name different + * Base URLs. + */ +export interface ProjectRecord { + key: string; + /** How a refusal names the record. */ + description: string; +} + +/** + * The record a directory's project comes from: a committed formio.json found by the + * upward walk, else that directory's own mapping. The environment is not asked — it + * answers for every directory alike, so it cannot tell one root from another. + * + * A broken record counts: a formio.json that cannot be read, or a mapping entry that + * is malformed or holds no usable URL, is still the record the user wrote for that + * directory, and choosing that root lets resolution fail naming it rather than + * silently targeting another root. A project map that cannot be read at all answers + * for no directory in particular, so it counts for none; a deployment mapped with no + * project beside it is not a project record either. + */ +export function projectRecordOf(dir: string): ProjectRecord | undefined { + const committedAt = (filePath: string): ProjectRecord => ({ + key: `committed:${filePath}`, + description: filePath, + }); + try { + const committed = findCommittedConfig(dir); + if (committed) { + return committedAt(committed.filePath); + } + } catch (error) { + return committedAt( + error instanceof CommittedConfigUnusableError + ? error.filePath + : path.join(dir, COMMITTED_CONFIG_FILE) + ); + } + const mapping: ProjectRecord = { + key: `mapping:${path.resolve(dir)}`, + description: `the project mapping for ${dir}`, + }; + try { + const entry = readProjectEntryForWrite(dir); + if (entry.status === 'unusable') { + return mapping; + } + return entry.status === 'usable' && entry.entry.env.FORMIO_PROJECT_URL ? mapping : undefined; + } catch { + return undefined; + } +} + +export interface ChooseRootRequest { + roots: string[]; + recordOf: (dir: string) => ProjectRecord | undefined; +} + +/** + * The root a call with no `cwd` resolves against, or undefined to try the next source. + * + * One root is used as is. Of several, the roots that resolve a project record decide: + * when they all resolve the same record, the first of them in the client's order is + * used; when none does, the roots decide nothing and the next source is tried, so a + * launch configured only by environment resolves as it always did. Roots resolving + * different records are refused, because no client but one documents what order its + * roots come in, and picking one by position targets a project nobody chose. + */ +export function chooseRoot({ roots, recordOf }: ChooseRootRequest): string | undefined { + if (roots.length <= 1) { + return roots[0]; + } + const recorded = roots.flatMap((root) => { + const record = recordOf(root); + return record ? [{ root, record }] : []; + }); + if (recorded.length === 0) { + return undefined; + } + if (new Set(recorded.map(({ record }) => record.key)).size === 1) { + return recorded[0].root; + } + throw new ToolError({ + code: 'INVALID_ARGUMENT', + message: `The client reports ${roots.length} workspace roots that resolve different Form.io project records: ${recorded.map(({ root, record }) => `${root} (${record.description})`).join(', ')}. Pass cwd set to the directory to use.`, + }); +} + +/** + * Whether a directory was NAMED for this call — by the caller's cwd or the client's + * workspace root. CLAUDE_PROJECT_DIR is a launch default and the server's own + * directory a spawn accident: both are guesses about where the user is, so an answer + * about either says so and asks for cwd. + */ +export function isNamedDirectory({ source }: WorkingDirectory): boolean { + return source === 'argument' || source === 'client-root'; +} + +/** How an answer names the server's own working directory. */ +export const SERVER_DIRECTORY = "the MCP server's own working directory"; + +/** How an answer names a directory that was guessed rather than named. */ +export function guessedDirectory({ source }: WorkingDirectory): string { + return source === 'claude-project-dir' + ? 'the directory CLAUDE_PROJECT_DIR names' + : SERVER_DIRECTORY; +} + +/** CLAUDE_PROJECT_DIR when it names an absolute path; anything else names no directory. */ +export function claudeProjectDir(value: string | undefined): string | undefined { + return value && path.isAbsolute(value) ? value : undefined; +} + +export interface WorkspaceDirectoryOptions { + /** How long `roots/list` may take; {@link ROOTS_TIMEOUT_MS} by default. */ + rootsTimeoutMs?: number; + /** Reads CLAUDE_PROJECT_DIR; the process environment, at call time, by default. */ + claudeProjectDir?: () => string | undefined; + recordOf?: (dir: string) => ProjectRecord | undefined; +} + +export interface ResolveDirectoryRequest { + /** The `cwd` argument, when the caller passed one. */ + cwd?: string; + /** The server process's own directory; `process.cwd()` by default. */ + serverCwd?: () => string; +} + +export interface WorkspaceDirectory { + resolve(request: ResolveDirectoryRequest): Promise; +} + +/** + * The directory source for one server connection. + * + * Roots are asked of the client only when it declared the `roots` capability, and + * the answer is kept until the client sends `notifications/roots/list_changed`. A + * request that fails or outlives the timeout does not discard what an earlier read + * returned: the last good list keeps serving, and only a client that has never + * answered counts as having no roots. That outcome is kept until the next + * `list_changed` too — asking again on every call would add the timeout to every + * call of a client that never answers. + */ +export function createWorkspaceDirectory( + server: Server, + options: WorkspaceDirectoryOptions = {} +): WorkspaceDirectory { + const timeout = options.rootsTimeoutMs ?? ROOTS_TIMEOUT_MS; + const readClaudeProjectDir = options.claudeProjectDir ?? (() => process.env.CLAUDE_PROJECT_DIR); + const recordOf = options.recordOf ?? projectRecordOf; + const cache: { read?: Promise; lastGood?: string[] } = {}; + + server.setNotificationHandler(RootsListChangedNotificationSchema, () => { + delete cache.read; + }); + + const readRoots = async (): Promise => { + try { + const { roots } = await server.request({ method: 'roots/list' }, RootsAnswer, { timeout }); + cache.lastGood = rootDirectories(roots); + return cache.lastGood; + } catch { + return cache.lastGood ?? []; + } + }; + + const clientRoots = (): Promise => { + if (!server.getClientCapabilities()?.roots) { + return Promise.resolve([]); + } + cache.read ??= readRoots(); + return cache.read; + }; + + return { + async resolve({ cwd, serverCwd = () => process.cwd() }) { + if (cwd !== undefined) { + return { dir: cwd, source: 'argument' }; + } + const root = chooseRoot({ roots: await clientRoots(), recordOf }); + if (root !== undefined) { + return { dir: root, source: 'client-root' }; + } + const projectDir = claudeProjectDir(readClaudeProjectDir()); + if (projectDir !== undefined) { + return { dir: projectDir, source: 'claude-project-dir' }; + } + return { dir: serverCwd(), source: 'server' }; + }, + }; +} + +const directories = new WeakMap(); + +/** + * The one directory source for an McpServer, created on first use so every tool on + * that server shares its roots cache and its `list_changed` handler. Creating it + * lazily loses nothing: until a call has read the roots there is nothing cached for + * a `list_changed` to invalidate. Options take effect on the call that creates it, so + * a caller that needs them calls this before the first tool call. + */ +export function workspaceDirectory( + server: McpServer, + options: WorkspaceDirectoryOptions = {} +): WorkspaceDirectory { + const existing = directories.get(server); + if (existing) { + return existing; + } + const created = createWorkspaceDirectory(server.server, options); + directories.set(server, created); + return created; +} diff --git a/packages/skill-tests/src/skill-descriptions/cwd-from-client-roots.test.ts b/packages/skill-tests/src/skill-descriptions/cwd-from-client-roots.test.ts new file mode 100644 index 00000000..5bfc9e4e --- /dev/null +++ b/packages/skill-tests/src/skill-descriptions/cwd-from-client-roots.test.ts @@ -0,0 +1,82 @@ +// With no `cwd` argument the server takes the directory from the client's workspace +// roots, then CLAUDE_PROJECT_DIR, and only then its own working directory, and +// `project_get` reports which of them supplied it as `cwdSource`. So the shared +// preflight calls `project_get` without `cwd`, and an agent passes `cwd` afterwards +// only when that report says `server` — the one source that is not where the user is +// working — or to target a different directory on purpose. +// +// `shared-prose-stays-identical` keeps every copy of the preflight the same; this +// keeps what that one text says right, and keeps the retired rule — pass `cwd` on +// every call — out of every skill document. + +import { readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { allSkills, repoRoot, toPosixPath } from './helpers.js'; + +const PREFLIGHT_LEAD = '**Available tools are not a configured project.**'; + +// The handoff target carries no preflight, and the planner calls no MCP tool. +const EXEMPT = ['formio-mcp-setup', 'formio-resource-planner']; + +function preflightOf(path: string): string | undefined { + return readFileSync(join(repoRoot, path), 'utf8') + .split('\n') + .find((line) => line.startsWith(PREFLIGHT_LEAD)); +} + +function markdownUnder(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + return markdownUnder(full); + } + return entry.isFile() && entry.name.endsWith('.md') ? [full] : []; + }); +} + +// Each shape the retired rule took: "pass `cwd` … on every Form.io tool call", "pass +// that same cwd on every call", and the reason it gave, which is no longer true — +// an omitted cwd now resolves the client's workspace root first. +const RETIRED = [ + /\bcwd\b[^.\n]*\bon every\b[^.\n]*\bcall\b/i, + /\bevery (Form\.io |project-scoped )?(tool )?call\b[^.\n]*\bcwd\b/i, + /omitting (it|`?cwd`?) resolves against the MCP server/i, +]; + +describe('the shared preflight lets the client supply the directory', () => { + const gated = allSkills().filter((skill) => !EXEMPT.includes(skill.directoryName)); + + it('has gated skills to check', () => { + expect(gated.length).toBeGreaterThan(1); + }); + + it.each(gated.map((skill) => skill.path))( + '%s calls project_get without cwd and passes cwd only when cwdSource is server', + (path) => { + const preflight = preflightOf(path); + + expect(preflight, `${path} has no shared preflight paragraph`).toBeDefined(); + expect(preflight).toMatch(/`project_get`[^.]*without `cwd`/); + expect(preflight).toMatch(/`cwdSource`[^.]*`server` or `claude-project-dir`[^.]*pass `cwd`/); + expect(preflight).toMatch(/only[^.]*another directory|another directory[^.]*only/); + } + ); +}); + +describe('no skill says to pass cwd on every call', () => { + const documents = markdownUnder(join(repoRoot, 'plugin/skills')).map((full) => + toPosixPath(relative(repoRoot, full)) + ); + + it('has skill documents to check', () => { + expect(documents.length).toBeGreaterThan(10); + }); + + it.each(documents)('%s', (path) => { + const text = readFileSync(join(repoRoot, path), 'utf8'); + const found = RETIRED.flatMap((pattern) => text.match(pattern)?.[0] ?? []); + + expect(found).toEqual([]); + }); +}); diff --git a/packages/skill-tests/src/skill-descriptions/preflight-blocking-scope.test.ts b/packages/skill-tests/src/skill-descriptions/preflight-blocking-scope.test.ts index 1d164734..6feb8ef6 100644 --- a/packages/skill-tests/src/skill-descriptions/preflight-blocking-scope.test.ts +++ b/packages/skill-tests/src/skill-descriptions/preflight-blocking-scope.test.ts @@ -97,7 +97,7 @@ describe('the preflight offers one remedy and invents none', () => { // The check names ONE CONCRETE TOOL rather than "the Form.io tools". An abstract check // invites an agent to reason about whether the tools are present but not yet usable, // and no such state exists here: measured against the built server over stdio with a - // clean HOME, `tools/list` returns all 21 tools, `form_list` among them, before any + // clean HOME, `tools/list` returns all 23 tools, `form_list` among them, before any // authentication and without writing a token cache. Auth moved off startup in // `lazy-auth-on-first-tool-call` and fires at the first API call. So `form_list` is // callable exactly when this server is connected, and the check needs nothing else. diff --git a/packages/skill-tests/src/skill-descriptions/preflight-writing-tools.test.ts b/packages/skill-tests/src/skill-descriptions/preflight-writing-tools.test.ts new file mode 100644 index 00000000..753d23ba --- /dev/null +++ b/packages/skill-tests/src/skill-descriptions/preflight-writing-tools.test.ts @@ -0,0 +1,52 @@ +// The shared preflight's last sentence names the calls that WRITE, so an agent states +// the resolved project before the first of them. A writing tool missing from that +// list is a write that lands on a deployment nobody confirmed. +// +// `shared-prose-stays-identical` keeps every copy the same; this keeps the one text +// they share complete. The two together fail an edit that reaches some copies only, +// and an edit that reaches every copy but leaves a writing tool out. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { allSkills, repoRoot } from './helpers.js'; + +// Every tool that changes the deployment. A read is not on the list: confirming the +// target before a read guards nothing that cannot be repeated. +const WRITING_TOOLS = [ + 'form_create', + 'form_update', + 'form_publish', + 'form_revert', + 'role_create', + 'role_update', + 'action_create', + 'action_update', + 'action_delete', + 'project_import', +]; + +// The handoff target carries no preflight, and the planner calls no MCP tool. +const EXEMPT = ['formio-mcp-setup', 'formio-resource-planner']; + +const WRITES_SENTENCE = /Before the first call that WRITES \(([^)]*)\)/; + +function writingToolsNamedIn(path: string): string[] | undefined { + const match = readFileSync(join(repoRoot, path), 'utf8').match(WRITES_SENTENCE); + return match ? [...match[1].matchAll(/`([a-z_]+)`/g)].map((name) => name[1]) : undefined; +} + +describe('the shared preflight names every writing tool', () => { + const gated = allSkills().filter((skill) => !EXEMPT.includes(skill.directoryName)); + + it('has gated skills to check', () => { + expect(gated.length).toBeGreaterThan(1); + }); + + it.each(gated.map((skill) => skill.path))('%s lists every writing tool', (path) => { + const named = writingToolsNamedIn(path); + + expect(named, `${path} has no "Before the first call that WRITES (…)" sentence`).toBeDefined(); + expect(named?.slice().sort()).toEqual(WRITING_TOOLS.slice().sort()); + }); +}); diff --git a/packages/skill-tests/src/skill-descriptions/retired-tool-surface.test.ts b/packages/skill-tests/src/skill-descriptions/retired-tool-surface.test.ts new file mode 100644 index 00000000..3db566a3 --- /dev/null +++ b/packages/skill-tests/src/skill-descriptions/retired-tool-surface.test.ts @@ -0,0 +1,71 @@ +// No document an agent or a person reads describes the tool surface 1.0 retired. +// +// The rules run against synthetic lines first, so a failure names the rule, and then +// over the shipped library and the READMEs, so a regression names the file and line. + +import { describe, expect, it } from 'vitest'; +import { allSkillDocuments, skillDocument } from './helpers.js'; +import { retiredSurfaceIssues, retiredSurfaceReport } from './retired-tool-surface.js'; + +const rulesFor = (body: string) => + retiredSurfaceIssues([{ path: 'synthetic.md', body }]).map((issue) => issue.rule); + +// The documents that describe the server's tools to a person installing it. +const READMES = [ + 'README.md', + 'packages/mcp-server/README.md', + 'packages/mcp-server/DOCKERHUB.md', + 'plugin/README.md', + 'llms-install.md', +]; + +describe('the retired-surface rules', () => { + it.each([ + ['Call `action_types_list` for the catalog.', 'renamed.action_types_list'], + ['List revisions with `form_revisions_list`.', 'renamed.form_revisions_list'], + ['Call the `hello` tool to check the wiring.', 'renamed.hello'], + ['Tools tab listing hello, form_create, form_get', 'renamed.hello'], + ['Use `form_update` with `publish: true`.', 'form_update.publish_or_revert'], + ['`draft`, `publish`, and `revert` are exclusive.', 'form_update.publish_or_revert'], + ['- `role_create` — add a role (`title`, `admin: false`).', 'role_create.flat_fields'], + ['`form_list` returns `forms` and `count`.', 'list.count'], + ['The result carries "count": 10.', 'list.count'], + ['`role_list` returns the count of roles on the page.', 'list.count'], + [ + 'Consent is cached in `~/.formio/revisions-license-consent.json`.', + 'revisions.consent_prompt', + ], + ['The tool prompts (elicitation, with a browser fallback).', 'revisions.consent_prompt'], + ['It asks for consent to save without revision tracking.', 'revisions.consent_prompt'], + ])('rejects %s', (body, rule) => { + expect(rulesFor(body)).toContain(rule); + }); + + it.each([ + ['The SDK translation example', " en: { hello: 'Hello' },"], + ['The SDK translation lookup', 'console.log(i18n.t(\'hello\')); // "Bonjour"'], + ['server_status', 'Call `server_status` first when other tools fail.'], + ['The current list tools', 'Call `action_type_list`, then `form_revision_list`.'], + ['Publish and revert as tools', 'Use `form_publish`, or `form_revert` with `version`.'], + ['A nested role', '- `role_create` — pass `role: { title, description }`.'], + ['The list contract', '`role_list` returns `roles`, `total` and `hasMore`.'], + ['A component word count', '| `validate.maxWords` | `number` | Maximum word count. |'], + ['acceptNoHistory', 'Retry with `acceptNoHistory: true` once the user agrees.'], + ])('accepts %s', (_label, body) => { + expect(rulesFor(body)).toEqual([]); + }); +}); + +describe('the shipped documents describe the 1.0 tool surface', () => { + it('no skill document names a retired tool, argument, field or prompt', () => { + const issues = retiredSurfaceIssues(allSkillDocuments()); + + expect(issues, retiredSurfaceReport(issues)).toEqual([]); + }); + + it.each(READMES)('%s names no retired tool, argument, field or prompt', (path) => { + const issues = retiredSurfaceIssues([skillDocument(path)]); + + expect(issues, retiredSurfaceReport(issues)).toEqual([]); + }); +}); diff --git a/packages/skill-tests/src/skill-descriptions/retired-tool-surface.ts b/packages/skill-tests/src/skill-descriptions/retired-tool-surface.ts new file mode 100644 index 00000000..3d0774f0 --- /dev/null +++ b/packages/skill-tests/src/skill-descriptions/retired-tool-surface.ts @@ -0,0 +1,91 @@ +// The tool surface 1.0 retired, as the lines of a document that still describe it. +// +// The server is the source of truth for tool names and arguments, and its own tests +// pin them. What they cannot see is the prose: a skill or README that still tells an +// agent to call `form_revisions_list`, to pass `publish: true` to `form_update`, or to +// expect a consent prompt sends that agent to a call the server now refuses — or to a +// prompt that never comes. + +export interface SurfaceDocument { + path: string; + body: string; +} + +export interface RetiredSurfaceIssue { + path: string; + line: number; + rule: string; + text: string; +} + +interface RetiredSurfaceRule { + rule: string; + matches: (line: string) => boolean; +} + +const LIST_TOOL = /\b(form_list|role_list|action_list|form_revision_list|action_type_list)\b/; +const TOOL_CONTEXT = /\btools?\b|\b(form|role|action|project|server)_[a-z_]+\b/i; + +const RULES: readonly RetiredSurfaceRule[] = [ + { + rule: 'renamed.action_types_list', + matches: (line) => /\baction_types_list\b/.test(line), + }, + { + rule: 'renamed.form_revisions_list', + matches: (line) => /\bform_revisions_list\b/.test(line), + }, + { + // `hello` became `server_status`. The SDK's i18n example keys a translation + // `hello`, which is not a tool, so only a mention in a tool's company counts. + rule: 'renamed.hello', + matches: (line) => /`hello`/.test(line) || (/\bhello\b/.test(line) && TOOL_CONTEXT.test(line)), + }, + { + rule: 'form_update.publish_or_revert', + matches: (line) => /`(publish|revert)`/.test(line) || /\b(publish|revert):\s*true\b/.test(line), + }, + { + // role_create takes `role: { title, … }`, as role_update does. + rule: 'role_create.flat_fields', + matches: (line) => + /\brole_create\b/.test(line) && + /`title`|\btitle:/.test(line) && + !/`role`|\brole:\s*\{|"role"\s*:/.test(line), + }, + { + rule: 'list.count', + matches: (line) => + /`count`/.test(line) || + /"count"\s*:/.test(line) || + (LIST_TOOL.test(line) && /\bcount\b/.test(line)), + }, + { + // Saving without history is refused with HISTORY_NOT_ACCEPTED and retried with + // acceptNoHistory; nothing prompts and nothing is remembered. + rule: 'revisions.consent_prompt', + matches: (line) => + /revisions-license-consent/.test(line) || + /\belicit/i.test(line) || + /\bconsent\b[^\n]*\brevision|\brevision[^\n]*\bconsent\b/i.test(line), + }, +]; + +export function retiredSurfaceIssues(documents: readonly SurfaceDocument[]): RetiredSurfaceIssue[] { + return documents.flatMap((document) => + document.body.split('\n').flatMap((text, index) => + RULES.filter((rule) => rule.matches(text)).map((rule) => ({ + path: document.path, + line: index + 1, + rule: rule.rule, + text: text.trim().slice(0, 160), + })) + ) + ); +} + +export function retiredSurfaceReport(issues: readonly RetiredSurfaceIssue[]): string { + return issues + .map((issue) => `${issue.path}:${issue.line} [${issue.rule}] ${issue.text}`) + .join('\n'); +} diff --git a/plugin/README.md b/plugin/README.md index 7708c460..2e2a2c89 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -42,9 +42,9 @@ Every step has an approval gate before any file is written or any MCP call hits ## What's in the box -- **MCP server** (`@formio/mcp`) — first-party Form.io operations as MCP tools (`form_*`, `role_*`, `action_*`, `project_*`). +- **MCP server** (`@formio/mcp`) — first-party Form.io operations as 23 MCP tools (`form_*`, `role_*`, `action_*`, `project_*`, and `server_status`, which reports the server version and project resolution without credentials). - **Skills library** — twelve activatable skills (app orchestration, form building, form embedding, planner, Angular and React framework implementors, schema, actions, auth, SDK, API router, MCP setup) plus a reference library under `formio-api/references/` covering every endpoint in the Form.io API Postman collection. -- **Per-directory project routing** — `project_set` maps a working directory to a Form.io project in `~/.formio/projects.json`, so each directory can target a different project. The server resolves that mapping on every tool call; `npx -y @formio/mcp@0.14.1 project get --cwd .` prints what it resolves and why. +- **Per-directory project routing** — `project_set` maps a working directory to a Form.io project in `~/.formio/projects.json`, so each directory can target a different project. The server resolves that mapping on every tool call; `npx -y @formio/mcp@0.14.1 project get --cwd "$(pwd)"` prints what it resolves and why. | Skill | Purpose | | --- | --- | diff --git a/plugin/skills/formio-actions/SKILL.md b/plugin/skills/formio-actions/SKILL.md index 1f2f213d..ce78f31b 100644 --- a/plugin/skills/formio-actions/SKILL.md +++ b/plugin/skills/formio-actions/SKILL.md @@ -22,11 +22,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. @@ -34,15 +34,17 @@ Never invent a Base URL, never reuse one from another project or an earlier sess When the user wants to manage actions on a live Form.io project, prefer the MCP server's first-party tools: -| Operation | MCP Tool | -| -------------------------------------- | ------------------- | -| List available action types | `action_types_list` | -| Get action type info + settings schema | `action_type_get` | -| Create an action on a form | `action_create` | -| List actions on a form | `action_list` | -| Get a single action | `action_get` | -| Update an action | `action_update` | -| Delete an action | `action_delete` | +| Operation | MCP Tool | +| -------------------------------------- | ------------------ | +| List available action types | `action_type_list` | +| Get action type info + settings schema | `action_type_get` | +| Create an action on a form | `action_create` | +| List actions on a form | `action_list` | +| Get a single action | `action_get` | +| Update an action | `action_update` | +| Delete an action | `action_delete` | + +Every action tool takes the form as `formId`, its 24-character `_id`; a form path is refused, so read the `_id` with `form_get` first when the user names the form by path. **Workflow for creating an action:** @@ -132,7 +134,7 @@ When multiple actions share the same priority, execution order is not guaranteed ## Action Types -Form.io ships 6 action types in the open-source server. Enterprise servers add more, of which this skill documents 5. The action type catalog is dynamic — always call `action_type_get` or `action_types_list` to discover what's available on the connected server. +Form.io ships 6 action types in the open-source server. Enterprise servers add more, of which this skill documents 5. The action type catalog is dynamic — always call `action_type_get` or `action_type_list` to discover what's available on the connected server. ### Quick Reference — Open Source diff --git a/plugin/skills/formio-actions/references/action-types.md b/plugin/skills/formio-actions/references/action-types.md index c26ca9f6..ef9da029 100644 --- a/plugin/skills/formio-actions/references/action-types.md +++ b/plugin/skills/formio-actions/references/action-types.md @@ -476,4 +476,4 @@ Handles login with a 2FA recovery code when the user has lost access to their au ## Action types this reference does not cover -Some Enterprise deployments expose action types beyond the eleven above — a server's catalog is dynamic, so `action_types_list` may return names documented nowhere here. Action types whose job is to copy submissions into an external system of record — a database, a spreadsheet, a third-party SaaS — are deliberately out of scope for this skill: configuring one is a server-administration task that needs credentials, a target schema, and grants belonging to whoever owns that system, not to a form-configuration flow. When a user asks for one, say it is not covered here and point them at their Form.io administrator or the Form.io documentation. Do not infer its settings from `action_type_get` output and configure it anyway. +Some Enterprise deployments expose action types beyond the eleven above — a server's catalog is dynamic, so `action_type_list` may return names documented nowhere here. Action types whose job is to copy submissions into an external system of record — a database, a spreadsheet, a third-party SaaS — are deliberately out of scope for this skill: configuring one is a server-administration task that needs credentials, a target schema, and grants belonging to whoever owns that system, not to a form-configuration flow. When a user asks for one, say it is not covered here and point them at their Form.io administrator or the Form.io documentation. Do not infer its settings from `action_type_get` output and configure it anyway. diff --git a/plugin/skills/formio-angular/SKILL.md b/plugin/skills/formio-angular/SKILL.md index e81e9367..6aa8592d 100644 --- a/plugin/skills/formio-angular/SKILL.md +++ b/plugin/skills/formio-angular/SKILL.md @@ -39,11 +39,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-angular/formio-angular-form/SKILL.md b/plugin/skills/formio-angular/formio-angular-form/SKILL.md index 4437fe0e..0259e182 100644 --- a/plugin/skills/formio-angular/formio-angular-form/SKILL.md +++ b/plugin/skills/formio-angular/formio-angular-form/SKILL.md @@ -107,11 +107,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-angular/formio-angular-resources/SKILL.md b/plugin/skills/formio-angular/formio-angular-resources/SKILL.md index a222a55b..fb8d8e47 100644 --- a/plugin/skills/formio-angular/formio-angular-resources/SKILL.md +++ b/plugin/skills/formio-angular/formio-angular-resources/SKILL.md @@ -24,11 +24,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-api/SKILL.md b/plugin/skills/formio-api/SKILL.md index e172504d..48b1fc6d 100644 --- a/plugin/skills/formio-api/SKILL.md +++ b/plugin/skills/formio-api/SKILL.md @@ -22,11 +22,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. @@ -39,7 +39,7 @@ Two distinct endpoints exist. These references NEVER conflate them: Both are values `project_get` reports and you substitute into the endpoint. Neither is read from the environment. -The Postman collection composes the project endpoint as `{{baseUrl}}/{{projectName}}`, but **never build a project URL that way yourself** — only one of the deployment routings puts the project on a path under the base URL, so composing it that way is wrong for the others. Read both values from the MCP server instead, by calling `project_get` with `cwd` set to the user's current working directory, and use exactly what it reports. +The Postman collection composes the project endpoint as `{{baseUrl}}/{{projectName}}`, but **never build a project URL that way yourself** — only one of the deployment routings puts the project on a path under the base URL, so composing it that way is wrong for the others. Read both values from the MCP server instead, by calling `project_get` as the preflight above does, and use exactly what it reports. Two consequences worth stating, because both look like mistakes and are not: `https://api.form.io/examples` is not a hosted project URL, and a project host that differs from the base URL's host is normal rather than an error. Never treat a `*.form.io` host as a base URL. @@ -51,7 +51,7 @@ All endpoints (except Server API health/status) require an `x-jwt-token` header ## MCP Tool Preference -Prefer first-party MCP tools (`form_create`, `form_get`, `form_list`, `form_update`, `role_create`, `role_list`, `role_update`, `project_export`, `project_import`) over raw HTTP when both paths exist. Each reference doc surfaces the preferred tool alongside its HTTP fallback in a mapping table. Authentication is implicit — any authenticated tool call triggers the portal-login flow on first use. +Prefer first-party MCP tools (`form_create`, `form_get`, `form_list`, `form_update`, `form_publish`, `form_revert`, `form_revision_list`, `form_revision_get`, `role_create`, `role_list`, `role_update`, `action_type_list`, `action_type_get`, `action_create`, `action_list`, `action_get`, `action_update`, `action_delete`, `project_export`, `project_import`) over raw HTTP when both paths exist. Each reference doc surfaces the preferred tool alongside its HTTP fallback in a mapping table. Authentication is implicit — any authenticated tool call triggers the portal-login flow on first use. The list tools return one page at a time, with `total` and `hasMore` beside the items — page with `skip` while `hasMore` is true. A failed tool call's text starts with a code in brackets, such as `[NOT_FOUND]` or `[HISTORY_NOT_ACCEPTED]`; branch on that code rather than on the wording after it. ## Scope map diff --git a/plugin/skills/formio-api/references/project-actions.md b/plugin/skills/formio-api/references/project-actions.md index c84392d7..bb1ed034 100644 --- a/plugin/skills/formio-api/references/project-actions.md +++ b/plugin/skills/formio-api/references/project-actions.md @@ -12,7 +12,19 @@ Every request to these endpoints MUST include an `x-jwt-token` header holding th ## MCP Tool Preference -No MCP tool covers this operation — use the HTTP endpoint directly. +Prefer the MCP server's first-party tools when they cover the requested operation. Call the HTTP endpoint directly only when no MCP tool applies. + +| Operation | Preferred MCP tool | Fallback endpoint | +| ----------------------------- | ------------------ | --------------------------------------------------- | +| List available action types | `action_type_list` | `GET {projectUrl}/form/:formId/actions` | +| Get an action type's settings | `action_type_get` | `GET {projectUrl}/form/:formId/actions/:actionName` | +| Attach an action to a form | `action_create` | `POST {projectUrl}/form/:formId/action` | +| List the actions on a form | `action_list` | `GET {projectUrl}/form/:formId/action` | +| Get one action | `action_get` | `GET {projectUrl}/form/:formId/action/:actionId` | +| Update an action | `action_update` | `PUT {projectUrl}/form/:formId/action/:actionId` | +| Remove an action from a form | `action_delete` | `DELETE {projectUrl}/form/:formId/action/:actionId` | + +Every action tool takes the form as `formId`, its 24-character `_id`; a form path is refused, so read the `_id` with `form_get` first. `action_list` returns one page at a time — `limit` (default 100), `skip`, `sort` and `select`, with `actions` beside `total` and `hasMore` in the result — and `action_type_list` returns the whole catalog, which Form.io does not page. ## Endpoints @@ -170,7 +182,7 @@ curl -H "x-jwt-token: $FORMIO_JWT" \ ### PUT {projectUrl}/form/:formId/action/:actionId -Update a configured action. This is a full replacement of the action's editable fields — include every field you want to preserve. +Update a configured action. Form.io applies the body to the stored document: each top-level field in the body overwrites the stored one, a top-level field left out keeps its stored value, and an array or object in the body replaces the stored one whole rather than merging into it. Send all of `settings`, `handler` and `method` when changing any part of them. | Path parameter | Type | Description | | -------------- | ------ | --------------------------------------------------- | diff --git a/plugin/skills/formio-api/references/project-form-revisions.md b/plugin/skills/formio-api/references/project-form-revisions.md index 353c3c7c..a5834af6 100644 --- a/plugin/skills/formio-api/references/project-form-revisions.md +++ b/plugin/skills/formio-api/references/project-form-revisions.md @@ -12,46 +12,47 @@ Every request to these endpoints MUST include an `x-jwt-token` header holding th ## 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. +- **Every form created via `form_create` defaults to `revisions: 'original'`** on a licensed deployment. The caller may override by passing `revisions: 'current'` on the form body; `revisions: ''` turns history off and needs `acceptNoHistory: true`. On an unlicensed deployment no form keeps history, so `form_create` saves only with `acceptNoHistory: true`, and then strips `revisions` from the body. +- **Every revision-writing call carries a revision note (`note`).** `form_update`, `form_publish` and `form_revert` require one; `form_create` takes one for the initial revision. The note is prefixed (`@formio/mcp:`) and persisted on the revision document — never skipped. ## MCP Tool Preference 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). +- **List revisions** (`GET /form/:id/v`) — use `form_revision_list`. Accepts the form by `_id` or path alias as `formIdOrPath`. It returns compact revision summaries (`_id`, `_vid`, `_vnote`, `_vuser`, `created`, `modified`) newest first, one page at a time: `limit` (default 100), `skip`, `sort` and `select` are its arguments, and the result carries `revisions` beside `total` and `hasMore` — call again with a larger `skip` while `hasMore` is true. +- **Get a single revision** (`GET /form/:id/v/:version`) — use `form_revision_get`. Accepts the form by `_id` or path alias as `formIdOrPath`; `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 fails with `NO_DRAFT` 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` leaves the stored value unchanged; `revisions: ""` turns history off and needs `acceptNoHistory: true` (see "Saving without history" below). +- **Save a draft** (`PUT /form/:id/draft`) — use `form_update` with `draft: true`. Only the draft fields of `form` are saved — `components`, `settings`, `tags`, `properties`, `controller`, `esign`, `display` — merged on top of the existing draft (caller wins), preserving prior unpublished edits. Every other field, including the server-owned ones `form_get` returns, is ignored, so `form_get`'s output can be edited and passed as is. A body carrying none of the draft fields is refused with `INVALID_ARGUMENT` and nothing is saved. +- **Publish the current draft** (`PUT /form/:id` from `/draft` body) — use `form_publish` with `formId` and `note`; it takes no form body. 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. A form with no draft fails with `NO_DRAFT` and nothing is written. `note` must 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_revert` with `formId`, `note`, 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. An unknown `version` fails with `NOT_FOUND`. 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 the user gives no other note, use `Reverted to version {version}`. -`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:`). +`form_update`, `form_publish` and `form_revert` take the form as `formId` — its 24-character `_id`. A form path is refused with `INVALID_ARGUMENT`; read the `_id` with `form_get` first. + +`note` is required on every `form_update`, `form_publish` and `form_revert` call. 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: +Drafts, publishing and reverting 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. +- `form_update` with `draft: true`, `form_publish` and `form_revert` — the tool refuses with `LICENSE_REQUIRED` and writes nothing. Change the form with a standard `form_update` instead. +- Standard `form_create` / `form_update` — the save keeps no revision history, so the tool refuses with `HISTORY_NOT_ACCEPTED` unless the call passes `acceptNoHistory: true`. With it, 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 +### Saving without history -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: +`acceptNoHistory: true` on `form_create` or `form_update` records that the user agreed to a save that keeps no revision history. Nothing asks the user on the tool's behalf: without that argument, a save that would keep no history is refused with `HISTORY_NOT_ACCEPTED`, nothing is written, and the error text says why. Relay that to the user, then retry with their answer. The refusal applies when ANY of the following holds: -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). +1. The deployment is not licensed for revisions (above). +2. The body sets `revisions: ""` — turning history off is a decision the user makes, on a licensed deployment as on any other. +3. A standard `form_update` on a licensed deployment targets a form whose stored `revisions` is disabled (falsy), and the body does not turn it on with `revisions: 'original' | 'current'`. Echoing the stored `""` back does not count as turning it on — that loophole would let an LLM silently skip the audit-trail decision on every form by always echoing the disabled value. -When all conditions hold, the tool prompts (elicitation, with a browser fallback) with three choices: +The user's three answers to the third case, and the retry each one means: -- **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. +- **Enable revisions (original)** — submissions render against the form version active when they were submitted. Retry with `revisions: 'original'` on the form body. +- **Enable revisions (current)** — submissions always render against the latest form version. Retry with `revisions: 'current'` on the form body. +- **Save without history (not tracked)** — retry with `acceptNoHistory: true`. -On cancel, the tool throws and no update is performed. +The answer is not remembered between calls: pass `acceptNoHistory: true` only on a save the user agreed to, and again on each later save that needs it. ## Endpoints diff --git a/plugin/skills/formio-api/references/project-forms.md b/plugin/skills/formio-api/references/project-forms.md index cca8e1b9..2a430c85 100644 --- a/plugin/skills/formio-api/references/project-forms.md +++ b/plugin/skills/formio-api/references/project-forms.md @@ -21,6 +21,8 @@ Prefer the MCP server's first-party tools when they cover the requested operatio | List forms | `form_list` | `GET {projectUrl}/form` | | Update a form | `form_update` | `PUT {projectUrl}/form/:idOrName` | +`form_list` returns one page at a time: `limit` (default 100), `skip`, `sort` and `select` are its arguments, `type` and `tags` filter it (`tags` matches forms carrying every tag given), and the result carries `forms` beside `total` and `hasMore` — call again with a larger `skip` while `hasMore` is true. `form_update` takes the form as `formId`, its 24-character `_id`, where `form_get` also accepts a path; read the `_id` with `form_get` first when the user names the form by path. Saving without revision history is refused unless the call passes `acceptNoHistory: true` — see [project-form-revisions](./project-form-revisions.md). + ## Endpoints ### GET {projectUrl}/form @@ -111,15 +113,15 @@ curl -X POST -H "x-jwt-token: $FORMIO_JWT" -H "Content-Type: application/json" \ ### PUT {projectUrl}/form/:idOrName -Replace an existing form definition. The body SHOULD include the `_id` of the form being updated (Form.io treats this as a full document replacement; omitted fields are reset to defaults). +Update an existing form definition. The body SHOULD include the `_id` of the form being updated. Form.io applies the body to the stored document: each top-level field in the body overwrites the stored one, a top-level field left out keeps its stored value, and an array or object in the body replaces the stored one whole rather than merging into it. -Request body: same shape as the create body, plus `_id`. Include every field you want to preserve. +Request body: same shape as the create body, plus `_id`. Send every component the form should keep — `components` is replaced whole. Response: the updated form document. Errors: `400` for validation errors; `404` if the form does not exist; `409` if `_vid` version checks fail (when revisions are enabled). -Note: prefer `PATCH` (via `runtime-submissions.md` patterns extended to forms) if partial updates are needed — this endpoint is a full replacement. +Note: because top-level fields left out keep their stored value, a partial update of top-level fields needs no `PATCH`; a change inside `components`, `settings` or `access` still sends that whole field. ### PUT {projectUrl}/:formPath diff --git a/plugin/skills/formio-api/references/project-roles.md b/plugin/skills/formio-api/references/project-roles.md index c06cdcb2..d387ec76 100644 --- a/plugin/skills/formio-api/references/project-roles.md +++ b/plugin/skills/formio-api/references/project-roles.md @@ -20,6 +20,8 @@ Prefer the MCP server's first-party tools when they cover the requested operatio | Create a role | `role_create` | `POST {projectUrl}/role` | | Update a role | `role_update` | `PUT {projectUrl}/role/:roleId` | +`role_create` and `role_update` both take the role document as a nested `role` object — `role_create` with `role: { title, description?, default?, admin? }`, the same fields the POST body below carries. `role_list` returns one page at a time: `limit` (default 100), `skip`, `sort` and `select` are its arguments, and the result carries `roles` beside `total` and `hasMore` — call again with a larger `skip` while `hasMore` is true. + ## Endpoints ### GET {projectUrl}/role @@ -94,7 +96,7 @@ curl -X POST -H "x-jwt-token: $FORMIO_JWT" -H "Content-Type: application/json" \ ### PUT {projectUrl}/role/:roleId -Update an existing role. This is a full replacement — include every field you want to preserve. +Update an existing role. Form.io applies the body to the stored document: each top-level field in the body overwrites the stored one, a top-level field left out keeps its stored value, and an array or object in the body replaces the stored one whole rather than merging into it. | Path parameter | Type | Description | | -------------- | ------ | ---------------------------------------- | diff --git a/plugin/skills/formio-application/IMPORT.md b/plugin/skills/formio-application/IMPORT.md index 1ba6751a..538185b9 100644 --- a/plugin/skills/formio-application/IMPORT.md +++ b/plugin/skills/formio-application/IMPORT.md @@ -96,10 +96,10 @@ Wait for explicit approval. Declining returns to the skip path (Step 4 next, no ### Call `project_import` -On approval, invoke the MCP tool with the template content loaded from the planner's `template.json` file path. Pass `cwd` — the same working directory the Preflight mapped. Every project-scoped tool resolves its project from that mapping, and omitting `cwd` resolves against the MCP server's own working directory instead, which is fixed at spawn: at best the import fails with "No Form.io project is configured", at worst it merges the template into whatever project that other directory is mapped to. +On approval, invoke the MCP tool with the template content loaded from the planner's `template.json` file path. Pass `cwd` set to the directory the Preflight's `project_get` reported (its `cwd` field), so the import resolves the same record that call named. A different directory can resolve a different record: at best the import fails with "No Form.io project is configured", at worst it merges the template into whatever project that other directory is mapped to. ``` -mcp__formio-mcp__project_import({ cwd: , template: }) +mcp__formio-mcp__project_import({ cwd: , template: }) ``` The tool returns the server's response text on success. Surface it to the user in one sentence ("Imported X resources, Y roles, Z forms into ``.") and advance to the config check. diff --git a/plugin/skills/formio-application/SKILL.md b/plugin/skills/formio-application/SKILL.md index 677a2e74..3ca05f75 100644 --- a/plugin/skills/formio-application/SKILL.md +++ b/plugin/skills/formio-application/SKILL.md @@ -22,13 +22,13 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. In this skill that first call is Step 3's import, and asking there is the whole of the configuration work. Not earlier — a Project URL asked for at Step 1 is asked for before the user has anything to put in it. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-auth/SKILL.md b/plugin/skills/formio-auth/SKILL.md index 4abd4da5..d5e3906b 100644 --- a/plugin/skills/formio-auth/SKILL.md +++ b/plugin/skills/formio-auth/SKILL.md @@ -18,11 +18,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-auth/references/roles-and-permissions.md b/plugin/skills/formio-auth/references/roles-and-permissions.md index efd62854..c1a2250c 100644 --- a/plugin/skills/formio-auth/references/roles-and-permissions.md +++ b/plugin/skills/formio-auth/references/roles-and-permissions.md @@ -126,7 +126,7 @@ Beyond the role-keyed `access` / `submissionAccess` arrays, Form.io supports fou ## MCP Tool Preference - `role_list` — discover existing role IDs in the project (default trio plus any custom). -- `role_create` — add a custom role (`title`, `description`, `admin: false`, `default: false`). +- `role_create` — add a custom role, passed as the nested `role` object: `role: { title, description, admin: false, default: false }`. - `role_update` — adjust an existing role's metadata. - `form_get` — read the current `access` / `submissionAccess` on a form before editing it. - `form_update` — write new `access` or `submissionAccess` arrays onto a Form or Resource. diff --git a/plugin/skills/formio-auth/references/token-swap.md b/plugin/skills/formio-auth/references/token-swap.md index 54846eea..66353932 100644 --- a/plugin/skills/formio-auth/references/token-swap.md +++ b/plugin/skills/formio-auth/references/token-swap.md @@ -42,7 +42,7 @@ OAuth Role Mapping is the bridge between an IdP role claim (e.g. `groups`, `role The swap is driven by the Form.io JavaScript SDK's `currentUser` call. Instantiate `Formio` against the project URL, attach the OAuth token as an `Authorization` header, and call `currentUser` with `external: true`: -**Where these two values come from.** The hosts below are illustrations — never ship one. Take both URLs from whichever of the two paths applies, per [`project-urls.md`](../../formio-mcp-setup/references/project-urls.md). **If the Form.io MCP tools are callable by you**, call `project_get` with `cwd` set to the user's current working directory and use exactly what it reports: its `projectUrl` for `setProjectUrl`, its `baseUrl` for `setBaseUrl`; if it reports a value missing, relay its instruction, persist the answer with `project_set`, and call it again. **If they are not, ask the user** — for the Project URL first and alone, deriving the Base URL from it, and asking for the Base URL only in the one shape where it cannot be derived. Do not install the MCP server to obtain these two values: writing them into an application reaches no deployment. Either way, do not hardcode an example host, do not derive either URL from the other, and do not carry a value over from another project or an earlier session — a wrong value here ships an application pointed at a deployment nobody is managing. +**Where these two values come from.** The hosts below are illustrations — never ship one. Take both URLs from whichever of the two paths applies, per [`project-urls.md`](../../formio-mcp-setup/references/project-urls.md). **If the Form.io MCP tools are callable by you**, call `project_get` as the preflight does — without `cwd`, unless its `cwdSource` was `server` or `claude-project-dir` — and use exactly what it reports: its `projectUrl` for `setProjectUrl`, its `baseUrl` for `setBaseUrl`; if it reports a value missing, relay its instruction, persist the answer with `project_set`, and call it again. **If they are not, ask the user** — for the Project URL first and alone, deriving the Base URL from it, and asking for the Base URL only in the one shape where it cannot be derived. Do not install the MCP server to obtain these two values: writing them into an application reaches no deployment. Either way, do not hardcode an example host, do not derive either URL from the other, and do not carry a value over from another project or an earlier session — a wrong value here ships an application pointed at a deployment nobody is managing. ```js import { Formio } from '@formio/js'; diff --git a/plugin/skills/formio-form-builder/SAVE.md b/plugin/skills/formio-form-builder/SAVE.md index a711a770..6b450f8d 100644 --- a/plugin/skills/formio-form-builder/SAVE.md +++ b/plugin/skills/formio-form-builder/SAVE.md @@ -48,6 +48,10 @@ Do not attempt PKCE or API keys — they are not how this server authenticates. The server rejected the definition. Quote the shortest decisive error line, route the fix back through `formio-schema` (Step 2 owns the definition), and offer the user a choice: retry with the corrected definition, or bail. Never hand-patch the JSON outside the schema skill. +### Saving without revision history (`HISTORY_NOT_ACCEPTED`) + +The save would keep no revision history for this form — the deployment is not licensed for revisions, or the definition turns `revisions` off — so the tool wrote nothing. Relay the reason the error gives in one line and ask the user whether to save without history. On a yes, retry the same `form_create` call with `acceptNoHistory: true`; on a no, stop as for a declined gate. In the edit lane the same refusal can come from `form_update` when the stored form has revisions off; its message names the retry for each answer — `revisions: "original"` or `"current"` on the form body to turn history on, or `acceptNoHistory: true` to save without it. + ### Project not found / wrong URL The target Project URL did not resolve. Re-run `project_get` for this directory and relay what it says; a typo or the wrong project recorded for this directory is the usual cause. diff --git a/plugin/skills/formio-form-builder/SKILL.md b/plugin/skills/formio-form-builder/SKILL.md index d12ba9d5..a92cf841 100644 --- a/plugin/skills/formio-form-builder/SKILL.md +++ b/plugin/skills/formio-form-builder/SKILL.md @@ -22,11 +22,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-form/SKILL.md b/plugin/skills/formio-form/SKILL.md index f041cf0f..396e2993 100644 --- a/plugin/skills/formio-form/SKILL.md +++ b/plugin/skills/formio-form/SKILL.md @@ -26,11 +26,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-form/references/setup.md b/plugin/skills/formio-form/references/setup.md index 4cc61baa..d3f0092c 100644 --- a/plugin/skills/formio-form/references/setup.md +++ b/plugin/skills/formio-form/references/setup.md @@ -58,7 +58,7 @@ These are the only two supported inclusion modes. Never import from `@formio/cor ## URL configuration -**Where these two values come from.** The hosts below are illustrations — never ship one. Take both URLs from whichever of the two paths applies, per [`project-urls.md`](../../formio-mcp-setup/references/project-urls.md). **If the Form.io MCP tools are callable by you**, call `project_get` with `cwd` set to the user's current working directory and use exactly what it reports: its `projectUrl` for `setProjectUrl`, its `baseUrl` for `setBaseUrl`; if it reports a value missing, relay its instruction, persist the answer with `project_set`, and call it again. **If they are not, ask the user** — for the Project URL first and alone, deriving the Base URL from it, and asking for the Base URL only in the one shape where it cannot be derived. Do not install the MCP server to obtain these two values: writing them into an application reaches no deployment. Either way, do not hardcode an example host, do not derive either URL from the other, and do not carry a value over from another project or an earlier session — a wrong value here ships an application pointed at a deployment nobody is managing. +**Where these two values come from.** The hosts below are illustrations — never ship one. Take both URLs from whichever of the two paths applies, per [`project-urls.md`](../../formio-mcp-setup/references/project-urls.md). **If the Form.io MCP tools are callable by you**, call `project_get` as the preflight does — without `cwd`, unless its `cwdSource` was `server` or `claude-project-dir` — and use exactly what it reports: its `projectUrl` for `setProjectUrl`, its `baseUrl` for `setBaseUrl`; if it reports a value missing, relay its instruction, persist the answer with `project_set`, and call it again. **If they are not, ask the user** — for the Project URL first and alone, deriving the Base URL from it, and asking for the Base URL only in the one shape where it cannot be derived. Do not install the MCP server to obtain these two values: writing them into an application reaches no deployment. Either way, do not hardcode an example host, do not derive either URL from the other, and do not carry a value over from another project or an earlier session — a wrong value here ships an application pointed at a deployment nobody is managing. Every deployment shape is handled by that one tool, so there is nothing to work out here: whichever routing the deployment uses, `project_get` reports the pair that matches it. diff --git a/plugin/skills/formio-mcp-setup/references/project-urls.md b/plugin/skills/formio-mcp-setup/references/project-urls.md index 3b172611..cfeaca64 100644 --- a/plugin/skills/formio-mcp-setup/references/project-urls.md +++ b/plugin/skills/formio-mcp-setup/references/project-urls.md @@ -21,10 +21,10 @@ Both paths apply the same rules, which are below and are the same rules the serv ## Path A — ask the server -Call `project_get` with `cwd` set to the user's current working directory, and branch on `status`: +Call `project_get` without `cwd` — the server takes the directory from the client's workspace root — and read `cwdSource` before `status`. When `cwdSource` is `server` or `claude-project-dir`, the answer is about a guessed directory — the MCP server's own, fixed at spawn, or a launch default — that may be mapped to a different project, so call `project_get` again with `cwd` set to the user's current working directory and keep passing that `cwd` on the calls after it. Otherwise pass `cwd` only to target another directory; a refusal listing several workspace roots is answered the same way, with `cwd` set to the one the user is working in. Then branch on `status`: - **`ok`** — both values are in the report. Use them exactly as reported. Do not ask the user to confirm them. -- **`not-configured`** — nothing is recorded for this directory. Ask the user for the Project URL (one question — see the rules below), record it by calling `project_set` with that `projectUrl` and the same `cwd`, then call `project_get` again. If that Project URL carries no path on the user's own domain it names no deployment, so the call is refused and names the Base URL it still needs: ask for that one value and repeat the call with both. +- **`not-configured`** — nothing is recorded for this directory. Ask the user for the Project URL (one question — see the rules below), record it by calling `project_set` with that `projectUrl` and the same `cwd`, if the `project_get` call passed one, then call `project_get` again. If that Project URL carries no path on the user's own domain it names no deployment, so the call is refused and names the Base URL it still needs: ask for that one value and repeat the call with both. - **`base-url-unresolved`** — the project is recorded and its deployment could not be derived. Ask the user for the Base URL alone, then make the `project_set` call the report names, and call `project_get` again. Do not re-ask the user for the Project URL: the report named it, and the call it prints carries it for you. If the call fails outright instead of returning a status, relay the error and stop; do not fall through to Path B. A recorded configuration that cannot be read is not an absent one, and interviewing around it writes a second answer that the broken record still shadows. diff --git a/plugin/skills/formio-react/SKILL.md b/plugin/skills/formio-react/SKILL.md index 9a0829e5..6a27f6eb 100644 --- a/plugin/skills/formio-react/SKILL.md +++ b/plugin/skills/formio-react/SKILL.md @@ -44,11 +44,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-react/formio-react-form/SKILL.md b/plugin/skills/formio-react/formio-react-form/SKILL.md index 4028e013..00cd49dd 100644 --- a/plugin/skills/formio-react/formio-react-form/SKILL.md +++ b/plugin/skills/formio-react/formio-react-form/SKILL.md @@ -36,11 +36,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-react/formio-react-resources/SKILL.md b/plugin/skills/formio-react/formio-react-resources/SKILL.md index 2702e990..f62b0337 100644 --- a/plugin/skills/formio-react/formio-react-resources/SKILL.md +++ b/plugin/skills/formio-react/formio-react-resources/SKILL.md @@ -24,11 +24,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-resource-planner/references/phase-b-emission.md b/plugin/skills/formio-resource-planner/references/phase-b-emission.md index ab075e1b..830e138f 100644 --- a/plugin/skills/formio-resource-planner/references/phase-b-emission.md +++ b/plugin/skills/formio-resource-planner/references/phase-b-emission.md @@ -54,7 +54,7 @@ After both blocks, tell the user the filenames that were written to disk (using Wrote ./template.md and ./template.json. ``` -Then finish with a short "Next steps" section. This is a **menu you print**, not work you do: this skill writes the artifact pair and hands it back, and it does not run any of the calls below. Whoever does — `formio-application` on its own flow, or you in a later turn once the user asks — owns the project gate for them, which means calling `project_get` with `cwd` set to the user's current working directory and naming the resolved Project URL before the first write. Never present the import as something already done. +Then finish with a short "Next steps" section. This is a **menu you print**, not work you do: this skill writes the artifact pair and hands it back, and it does not run any of the calls below. Whoever does — `formio-application` on its own flow, or you in a later turn once the user asks — owns the project gate for them, which means calling `project_get` — passing `cwd` only when its `cwdSource` reports `server` or `claude-project-dir`, or the target is another directory — and naming the resolved Project URL before the first write. Never present the import as something already done. ``` ### Next steps @@ -62,7 +62,7 @@ Then finish with a short "Next steps" section. This is a **menu you print**, not - **Import directly**: `project_import` (MCP tool) with the contents of `template.json`; fall back to `curl -X POST -H "x-jwt-token: $JWT" -H "Content-Type: application/json" -d "{\"template\": $(cat template.json)}" {projectUrl}/import` only when the MCP server is unavailable, substituting the Project URL the user gave you — `project_get` is a tool on the server this fallback exists for, so there is nothing to ask - **Or pass the pair to another skill / tool**: - `form_create` (MCP tool) for each entry under `resources` and `forms` in `template.json` - - `action_create` (MCP tool) for each entry under `actions`; the REST shapes live in `formio-api/references/project-actions` if the MCP server is unavailable - - `role_create` (MCP tool) for the non-default roles listed under `roles`; REST fallback in `formio-api/references/project-roles` + - `action_create` (MCP tool) for each entry under `actions`, passing the created form's `_id` as `formId` (a form path is refused); the REST shapes live in `formio-api/references/project-actions` if the MCP server is unavailable + - `role_create` (MCP tool) for the non-default roles listed under `roles`, each passed as the nested `role` object (`role: { title, description }`); REST fallback in `formio-api/references/project-roles` - Framework scaffolders (`formio-angular/formio-angular-resources`, future `formio-react`, …) — hand them BOTH `template.md` (architectural intent) and `template.json` (structured reference). The `.md` is the seed they reason from; the `.json` is the structured companion they consult for field-level shapes. ``` diff --git a/plugin/skills/formio-schema/SKILL.md b/plugin/skills/formio-schema/SKILL.md index 502db793..a600e07e 100644 --- a/plugin/skills/formio-schema/SKILL.md +++ b/plugin/skills/formio-schema/SKILL.md @@ -27,11 +27,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. diff --git a/plugin/skills/formio-sdk/SKILL.md b/plugin/skills/formio-sdk/SKILL.md index 96e19ab1..927a7a6c 100644 --- a/plugin/skills/formio-sdk/SKILL.md +++ b/plugin/skills/formio-sdk/SKILL.md @@ -22,11 +22,11 @@ That ban is on **build-time** work — the configuring you do in this session. I ## Which project the tools target -**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, so pass `cwd` — the user's current working directory — on every Form.io tool call; omitting it resolves against the MCP server's own directory, which is fixed at spawn and may be mapped to a different project. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool with `cwd` set to the user's current working directory. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. +**Available tools are not a configured project.** Every Form.io tool resolves which project it targets per working directory, and with no `cwd` argument the server takes that directory from the client's workspace root. Before the first call that reads from or writes to a deployment, ask the server what this directory resolves to by calling the `project_get` tool without `cwd`, and read the `cwdSource` it reports. When `cwdSource` is `server` or `claude-project-dir`, the directory is a guess — the MCP server's own, fixed at spawn, or a launch default — and may be mapped to a different project, so pass `cwd` — the user's current working directory — on a second `project_get` call and on the Form.io tool calls after it. Otherwise pass `cwd` only to target another directory, such as one application in a monorepo; and if `project_get` refuses because the client reports several workspace roots, pass `cwd` set to the one the user is working in. Do not shell out for this: the connected server answers it directly, with the same resolver every other tool uses, so what it reports is what the next call targets. If `project_get` is not callable, the connected server predates it — load the `formio-mcp-setup` skill, which moves the pinned version forward. What `project_get` returns IS the configuration. There is one value to think about — the **Project URL**, the full URL of the Form.io project this work reads and writes. The **Base URL** (the deployment hosting it) is normally DERIVED from that project URL rather than supplied, so it is not a second thing to ask for. The values may come from a committed `formio.json` tracked with the application's own source, from this directory's mapping, or from the environment — the report says which. Do not ask the user to confirm or re-supply either one. -Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `role_create`, `action_create`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. +Branch on the `status` it returns. On `ok`, proceed. On `not-configured` — nothing is recorded for this directory — relay that message's own instruction to the user, ask for the single value it names, record it with `project_set`, and call `project_get` again. On `base-url-unresolved` the project IS recorded and one named value is still missing — the Base URL, for a project URL that names no deployment of its own: relay that message the same way, ask the user for that one value, and do exactly what that message names — which record the deployment goes in decides what the fix IS, and the report names it rather than leaving you to compose one. For a project this directory's own mapping holds, that is a `project_set` call, and the report also carries it as a structured `remedy`. For a project a committed `formio.json` holds, it is an EDIT to that file — the report names the path and the key, there is no `remedy` field to act on, and this server never writes a committed file, so composing a `project_set` call there is refused. Then call `project_get` again. Do not re-ask the user for the Project URL there; the report already reported it, and the call it names carries it for you. If the call fails outright instead of returning a status, it could not answer at all (an unreadable `~/.formio/projects.json`, a `formio.json` that will not parse, a malformed URL): do NOT interview, because a `project_set` would fail for the same unreported reason and the loop would repeat with the cause never named — relay the error and stop until it is fixed. Before the first call that WRITES (`form_create`, `form_update`, `form_publish`, `form_revert`, `role_create`, `role_update`, `action_create`, `action_update`, `action_delete`, `project_import`), state the resolved Project URL and Base URL in one line, so a wrong target is caught before anything is written to it. Never invent a Base URL, never reuse one from another project or an earlier session, and never edit `~/.formio/projects.json` by any means — its shape, its `0600` mode, and its merge rules belong to the server, and `project_set` is how you reach it. The server's own messages carry the URL shapes and the remedy for each; this skill does not restate them. @@ -54,7 +54,7 @@ Never use `@formio/js/lib/...` deep imports or `